diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index c5bd2191c4..09ddef3781 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -70,6 +70,7 @@ + diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 576d2c5c54..c36761c9fc 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -453,6 +453,10 @@ + + + + diff --git a/dotnet/eng/MSBuild/Shared.props b/dotnet/eng/MSBuild/Shared.props index 94ac5b417b..1a3feb4c4f 100644 --- a/dotnet/eng/MSBuild/Shared.props +++ b/dotnet/eng/MSBuild/Shared.props @@ -29,4 +29,7 @@ + + + diff --git a/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProvider.cs b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProvider.cs index 35baa055d1..6f9f37518e 100644 --- a/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProvider.cs @@ -10,6 +10,7 @@ using System.Threading.Tasks; using Azure.AI.Projects; using Microsoft.Extensions.AI; +using Microsoft.Extensions.Compliance.Redaction; using Microsoft.Extensions.Logging; using Microsoft.Shared.DiagnosticIds; using Microsoft.Shared.Diagnostics; @@ -37,7 +38,7 @@ public sealed class FoundryMemoryProvider : AIContextProvider private readonly string _memoryStoreName; private readonly int _maxMemories; private readonly int _updateDelay; - private readonly bool _enableSensitiveTelemetryData; + private readonly Redactor _redactor; private readonly AIProjectClient _client; private readonly ILogger? _logger; @@ -79,7 +80,7 @@ public FoundryMemoryProvider( this._memoryStoreName = memoryStoreName; this._maxMemories = effectiveOptions.MaxMemories; this._updateDelay = effectiveOptions.UpdateDelay; - this._enableSensitiveTelemetryData = effectiveOptions.EnableSensitiveTelemetryData; + this._redactor = effectiveOptions.EnableSensitiveTelemetryData ? NullRedactor.Instance : (effectiveOptions.Redactor ?? new ReplacingRedactor("")); } /// @@ -416,7 +417,7 @@ private static MessageResponseItem ToResponseItem(ChatRole role, string text) private static bool IsAllowedRole(ChatRole role) => role == ChatRole.User || role == ChatRole.Assistant || role == ChatRole.System; - private string? SanitizeLogData(string? data) => this._enableSensitiveTelemetryData ? data : ""; + private string SanitizeLogData(string? data) => this._redactor.Redact(data); /// /// Represents the state of a stored in the . diff --git a/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProviderOptions.cs b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProviderOptions.cs index 870fe1d271..cf4fb5ab15 100644 --- a/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProviderOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProviderOptions.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; using Microsoft.Extensions.AI; +using Microsoft.Extensions.Compliance.Redaction; namespace Microsoft.Agents.AI.FoundryMemory; @@ -37,8 +38,22 @@ public sealed class FoundryMemoryProviderOptions /// Gets or sets a value indicating whether sensitive data such as user ids and user messages may appear in logs. /// /// Defaults to . + /// + /// When set to , sensitive data is passed through to logs unchanged and any + /// configured is ignored. This property takes precedence over . + /// public bool EnableSensitiveTelemetryData { get; set; } + /// + /// Gets or sets a custom used to redact sensitive data in log output. + /// + /// + /// When (the default), sensitive data is replaced with a placeholder. + /// When set, this redactor is used to transform sensitive values before they are logged. + /// Ignored when is . + /// + public Redactor? Redactor { get; set; } + /// /// Gets or sets the key used to store the provider state in the session's . /// 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 a1b8f85ae8..7abc3d0bcc 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 @@ -8,6 +8,7 @@ true true + true true true @@ -20,6 +21,7 @@ + diff --git a/dotnet/src/Microsoft.Agents.AI.Mem0/Mem0Provider.cs b/dotnet/src/Microsoft.Agents.AI.Mem0/Mem0Provider.cs index d7c54e2114..8be799ac1a 100644 --- a/dotnet/src/Microsoft.Agents.AI.Mem0/Mem0Provider.cs +++ b/dotnet/src/Microsoft.Agents.AI.Mem0/Mem0Provider.cs @@ -8,6 +8,7 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.AI; +using Microsoft.Extensions.Compliance.Redaction; using Microsoft.Extensions.Logging; using Microsoft.Shared.Diagnostics; @@ -51,7 +52,7 @@ public sealed class Mem0Provider : MessageAIContextProvider private readonly ProviderSessionState _sessionState; private IReadOnlyList? _stateKeys; private readonly string _contextPrompt; - private readonly bool _enableSensitiveTelemetryData; + private readonly Redactor _redactor; private readonly Mem0Client _client; private readonly ILogger? _logger; @@ -91,7 +92,7 @@ public Mem0Provider(HttpClient httpClient, Func stateIniti this._client = new Mem0Client(httpClient); this._contextPrompt = options?.ContextPrompt ?? DefaultContextPrompt; - this._enableSensitiveTelemetryData = options?.EnableSensitiveTelemetryData ?? false; + this._redactor = options?.EnableSensitiveTelemetryData == true ? NullRedactor.Instance : (options?.Redactor ?? new ReplacingRedactor("")); } /// @@ -297,5 +298,5 @@ public State(Mem0ProviderScope storageScope, Mem0ProviderScope? searchScope = nu public Mem0ProviderScope SearchScope { get; } } - private string? SanitizeLogData(string? data) => this._enableSensitiveTelemetryData ? data : ""; + private string SanitizeLogData(string? data) => this._redactor.Redact(data); } diff --git a/dotnet/src/Microsoft.Agents.AI.Mem0/Mem0ProviderOptions.cs b/dotnet/src/Microsoft.Agents.AI.Mem0/Mem0ProviderOptions.cs index 4a3a16712f..2c09bf9a7d 100644 --- a/dotnet/src/Microsoft.Agents.AI.Mem0/Mem0ProviderOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Mem0/Mem0ProviderOptions.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; using Microsoft.Extensions.AI; +using Microsoft.Extensions.Compliance.Redaction; namespace Microsoft.Agents.AI.Mem0; @@ -21,8 +22,22 @@ public sealed class Mem0ProviderOptions /// Gets or sets a value indicating whether sensitive data such as user ids and user messages may appear in logs. /// /// Defaults to . + /// + /// When set to , sensitive data is passed through to logs unchanged and any + /// configured is ignored. This property takes precedence over . + /// public bool EnableSensitiveTelemetryData { get; set; } + /// + /// Gets or sets a custom used to redact sensitive data in log output. + /// + /// + /// When (the default), sensitive data is replaced with a placeholder. + /// When set, this redactor is used to transform sensitive values before they are logged. + /// Ignored when is . + /// + public Redactor? Redactor { get; set; } + /// /// Gets or sets the key used to store the provider state in the session's . /// diff --git a/dotnet/src/Microsoft.Agents.AI.Mem0/Microsoft.Agents.AI.Mem0.csproj b/dotnet/src/Microsoft.Agents.AI.Mem0/Microsoft.Agents.AI.Mem0.csproj index 52bcdda165..9612b3d4b0 100644 --- a/dotnet/src/Microsoft.Agents.AI.Mem0/Microsoft.Agents.AI.Mem0.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Mem0/Microsoft.Agents.AI.Mem0.csproj @@ -6,6 +6,7 @@ true + true true @@ -23,6 +24,10 @@ + + + + Microsoft Agent Framework - Mem0 integration diff --git a/dotnet/src/Microsoft.Agents.AI/Memory/ChatHistoryMemoryProvider.cs b/dotnet/src/Microsoft.Agents.AI/Memory/ChatHistoryMemoryProvider.cs index 6881f7303f..2bc8408a26 100644 --- a/dotnet/src/Microsoft.Agents.AI/Memory/ChatHistoryMemoryProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI/Memory/ChatHistoryMemoryProvider.cs @@ -7,6 +7,7 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.AI; +using Microsoft.Extensions.Compliance.Redaction; using Microsoft.Extensions.Logging; using Microsoft.Extensions.VectorData; using Microsoft.Shared.Diagnostics; @@ -80,7 +81,7 @@ public sealed class ChatHistoryMemoryProvider : MessageAIContextProvider, IDispo private readonly VectorStoreCollection> _collection; private readonly int _maxResults; private readonly string _contextPrompt; - private readonly bool _enableSensitiveTelemetryData; + private readonly Redactor _redactor; private readonly ChatHistoryMemoryProviderOptions.SearchBehavior _searchTime; private readonly string _toolName; private readonly string _toolDescription; @@ -118,7 +119,7 @@ public ChatHistoryMemoryProvider( options ??= new ChatHistoryMemoryProviderOptions(); this._maxResults = options.MaxResults.HasValue ? Throw.IfLessThanOrEqual(options.MaxResults.Value, 0) : DefaultMaxResults; this._contextPrompt = options.ContextPrompt ?? DefaultContextPrompt; - this._enableSensitiveTelemetryData = options.EnableSensitiveTelemetryData; + this._redactor = options.EnableSensitiveTelemetryData ? NullRedactor.Instance : (options.Redactor ?? new ReplacingRedactor("")); this._searchTime = options.SearchTime; this._logger = loggerFactory?.CreateLogger(); this._toolName = options.FunctionToolName ?? DefaultFunctionToolName; @@ -485,7 +486,7 @@ public void Dispose() GC.SuppressFinalize(this); } - private string? SanitizeLogData(string? data) => this._enableSensitiveTelemetryData ? data : ""; + private string SanitizeLogData(string? data) => this._redactor.Redact(data); /// /// Rebinds a filter expression's body to use the specified shared parameter, diff --git a/dotnet/src/Microsoft.Agents.AI/Memory/ChatHistoryMemoryProviderOptions.cs b/dotnet/src/Microsoft.Agents.AI/Memory/ChatHistoryMemoryProviderOptions.cs index a9c5b93928..db1731a54d 100644 --- a/dotnet/src/Microsoft.Agents.AI/Memory/ChatHistoryMemoryProviderOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI/Memory/ChatHistoryMemoryProviderOptions.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; using Microsoft.Extensions.AI; +using Microsoft.Extensions.Compliance.Redaction; namespace Microsoft.Agents.AI; @@ -46,8 +47,22 @@ public sealed class ChatHistoryMemoryProviderOptions /// Gets or sets a value indicating whether sensitive data such as user ids and user messages may appear in logs. /// /// Defaults to . + /// + /// When set to , sensitive data is passed through to logs unchanged and any + /// configured is ignored. This property takes precedence over . + /// public bool EnableSensitiveTelemetryData { get; set; } + /// + /// Gets or sets a custom used to redact sensitive data in log output. + /// + /// + /// When (the default), sensitive data is replaced with a placeholder. + /// When set, this redactor is used to transform sensitive values before they are logged. + /// Ignored when is . + /// + public Redactor? Redactor { get; set; } + /// /// Gets or sets the key used to store provider state in the . /// diff --git a/dotnet/src/Microsoft.Agents.AI/Microsoft.Agents.AI.csproj b/dotnet/src/Microsoft.Agents.AI/Microsoft.Agents.AI.csproj index 93b228d29e..70da404a61 100644 --- a/dotnet/src/Microsoft.Agents.AI/Microsoft.Agents.AI.csproj +++ b/dotnet/src/Microsoft.Agents.AI/Microsoft.Agents.AI.csproj @@ -8,6 +8,7 @@ true true + true true true true @@ -22,6 +23,7 @@ + diff --git a/dotnet/src/Microsoft.Agents.AI/TextSearchProvider.cs b/dotnet/src/Microsoft.Agents.AI/TextSearchProvider.cs index e389b02294..7a48243ef6 100644 --- a/dotnet/src/Microsoft.Agents.AI/TextSearchProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI/TextSearchProvider.cs @@ -7,6 +7,7 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.AI; +using Microsoft.Extensions.Compliance.Redaction; using Microsoft.Extensions.Logging; using Microsoft.Shared.Diagnostics; @@ -62,6 +63,7 @@ public sealed class TextSearchProvider : MessageAIContextProvider private readonly string _contextPrompt; private readonly string _citationsPrompt; private readonly Func, string>? _contextFormatter; + private readonly Redactor _redactor; /// /// Initializes a new instance of the class. @@ -89,6 +91,7 @@ public TextSearchProvider( this._contextPrompt = options?.ContextPrompt ?? DefaultContextPrompt; this._citationsPrompt = options?.CitationsPrompt ?? DefaultCitationsPrompt; this._contextFormatter = options?.ContextFormatter; + this._redactor = options?.EnableSensitiveTelemetryData == true ? NullRedactor.Instance : (options?.Redactor ?? new ReplacingRedactor("")); // Create the on-demand search tool (only used if behavior is OnDemandFunctionCalling) this._tools = @@ -180,7 +183,7 @@ protected override async ValueTask> ProvideMessagesAsyn if (this._logger?.IsEnabled(LogLevel.Trace) is true) { - this._logger.LogTrace("TextSearchProvider: Search Results\nInput:{Input}\nOutput:{MessageText}", input, formatted); + this._logger.LogTrace("TextSearchProvider: Search Results\nInput:{Input}\nOutput:{MessageText}", this.SanitizeLogData(input), this.SanitizeLogData(formatted)); } return [new ChatMessage(ChatRole.User, formatted)]; @@ -249,7 +252,7 @@ internal async Task SearchAsync(string userQuestion, CancellationToken c if (this._logger.IsEnabled(LogLevel.Trace)) { - this._logger.LogTrace("TextSearchProvider Input:{UserQuestion}\nOutput:{MessageText}", userQuestion, outputText); + this._logger.LogTrace("TextSearchProvider Input:{UserQuestion}\nOutput:{MessageText}", this.SanitizeLogData(userQuestion), this.SanitizeLogData(outputText)); } } @@ -325,6 +328,8 @@ public sealed class TextSearchResult public object? RawRepresentation { get; set; } } + private string SanitizeLogData(string? data) => this._redactor.Redact(data); + /// /// Represents the per-session state of a stored in the . /// diff --git a/dotnet/src/Microsoft.Agents.AI/TextSearchProviderOptions.cs b/dotnet/src/Microsoft.Agents.AI/TextSearchProviderOptions.cs index 879e34121d..9c6845ff21 100644 --- a/dotnet/src/Microsoft.Agents.AI/TextSearchProviderOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI/TextSearchProviderOptions.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; using Microsoft.Extensions.AI; +using Microsoft.Extensions.Compliance.Redaction; namespace Microsoft.Agents.AI; @@ -117,6 +118,26 @@ public sealed class TextSearchProviderOptions /// public List? RecentMessageRolesIncluded { get; set; } + /// + /// Gets or sets a value indicating whether sensitive data such as user queries and search results may appear in logs. + /// + /// Defaults to . + /// + /// When set to , sensitive data is passed through to logs unchanged and any + /// configured is ignored. This property takes precedence over . + /// + public bool EnableSensitiveTelemetryData { get; set; } + + /// + /// Gets or sets a custom used to redact sensitive data in log output. + /// + /// + /// When (the default), sensitive data is replaced with a placeholder. + /// When set, this redactor is used to transform sensitive values before they are logged. + /// Ignored when is . + /// + public Redactor? Redactor { get; set; } + /// /// Behavior choices for the provider. /// diff --git a/dotnet/src/Shared/Redaction/README.md b/dotnet/src/Shared/Redaction/README.md new file mode 100644 index 0000000000..01683d0a54 --- /dev/null +++ b/dotnet/src/Shared/Redaction/README.md @@ -0,0 +1,30 @@ +# Redaction + +Log data redaction utilities built on `Microsoft.Extensions.Compliance.Redaction.Redactor`. + +Provides `ReplacingRedactor`, an internal `Redactor` implementation that replaces +any input with a fixed replacement string (e.g. `""`). + +To use this in your project, add the following to your `.csproj` file: + +```xml + + true + +``` + +You will also need to add a package reference to `Microsoft.Extensions.Compliance.Abstractions`: + +```xml + + + +``` + +And finally, this also depends on the shared Throw class, so when using redaction, InjectSharedThrow should also be enabled: + +```xml + + true + +``` \ No newline at end of file diff --git a/dotnet/src/Shared/Redaction/ReplacingRedactor.cs b/dotnet/src/Shared/Redaction/ReplacingRedactor.cs new file mode 100644 index 0000000000..3e97e7318e --- /dev/null +++ b/dotnet/src/Shared/Redaction/ReplacingRedactor.cs @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using Microsoft.Extensions.Compliance.Redaction; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// +/// A that replaces the entire input with a fixed replacement string. +/// +internal sealed class ReplacingRedactor : Redactor +{ + private readonly string _replacementText; + + /// + /// Initializes a new instance of the class. + /// + /// The text to substitute for any input value. + /// Thrown when is . + public ReplacingRedactor(string replacementText) + { + this._replacementText = Throw.IfNull(replacementText); + } + + /// + public override int GetRedactedLength(ReadOnlySpan input) => this._replacementText.Length; + + /// + public override int Redact(ReadOnlySpan source, Span destination) + { + this._replacementText.AsSpan().CopyTo(destination); + return this._replacementText.Length; + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Mem0.UnitTests/Mem0ProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.Mem0.UnitTests/Mem0ProviderTests.cs index 3374270861..a959faa515 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Mem0.UnitTests/Mem0ProviderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Mem0.UnitTests/Mem0ProviderTests.cs @@ -148,11 +148,15 @@ public async Task InvokingAsync_PerformsSearch_AndReturnsContextMessageAsync() } [Theory] - [InlineData(false, false, 4)] - [InlineData(true, false, 4)] - [InlineData(false, true, 2)] - [InlineData(true, true, 2)] - public async Task InvokingAsync_LogsUserIdBasedOnEnableSensitiveTelemetryDataAsync(bool enableSensitiveTelemetryData, bool requestThrows, int expectedLogInvocations) + [InlineData(false, false, false, 4)] + [InlineData(false, false, true, 4)] + [InlineData(true, false, false, 4)] + [InlineData(true, false, true, 4)] + [InlineData(false, true, false, 2)] + [InlineData(false, true, true, 2)] + [InlineData(true, true, false, 2)] + [InlineData(true, true, true, 2)] + public async Task InvokingAsync_RedactsLogDataBasedOnOptionsAsync(bool enableSensitiveTelemetryData, bool requestThrows, bool useCustomRedactor, int expectedLogInvocations) { // Arrange if (requestThrows) @@ -171,7 +175,11 @@ public async Task InvokingAsync_LogsUserIdBasedOnEnableSensitiveTelemetryDataAsy ThreadId = "session", UserId = "user" }; - var options = new Mem0ProviderOptions { EnableSensitiveTelemetryData = enableSensitiveTelemetryData }; + var options = new Mem0ProviderOptions + { + EnableSensitiveTelemetryData = enableSensitiveTelemetryData, + Redactor = useCustomRedactor ? new ReplacingRedactor("***") : null + }; var mockSession = new TestAgentSession(); var sut = new Mem0Provider(this._httpClient, _ => new Mem0Provider.State(storageScope), options: options, loggerFactory: this._loggerFactoryMock.Object); @@ -180,7 +188,8 @@ public async Task InvokingAsync_LogsUserIdBasedOnEnableSensitiveTelemetryDataAsy // Act await sut.InvokingAsync(invokingContext, CancellationToken.None); - // Assert + // Assert — EnableSensitiveTelemetryData takes precedence over Redactor + string expectedRedaction = enableSensitiveTelemetryData ? "user" : (useCustomRedactor ? "***" : ""); Assert.Equal(expectedLogInvocations, this._loggerMock.Invocations.Count); foreach (var logInvocation in this._loggerMock.Invocations) { @@ -191,18 +200,18 @@ public async Task InvokingAsync_LogsUserIdBasedOnEnableSensitiveTelemetryDataAsy var state = Assert.IsType>>(logInvocation.Arguments[2], exactMatch: false); var userIdValue = state.First(kvp => kvp.Key == "UserId").Value; - Assert.Equal(enableSensitiveTelemetryData ? "user" : "", userIdValue); + Assert.Equal(expectedRedaction, userIdValue); var inputValue = state.FirstOrDefault(kvp => kvp.Key == "Input").Value; if (inputValue != null) { - Assert.Equal(enableSensitiveTelemetryData ? "Who am I?" : "", inputValue); + Assert.Equal(enableSensitiveTelemetryData ? "Who am I?" : expectedRedaction, inputValue); } var messageTextValue = state.FirstOrDefault(kvp => kvp.Key == "MessageText").Value; if (messageTextValue != null) { - Assert.Equal(enableSensitiveTelemetryData ? "## Memories\nConsider the following memories when answering user questions:\nName is Caoimhe" : "", messageTextValue); + Assert.Equal(enableSensitiveTelemetryData ? "## Memories\nConsider the following memories when answering user questions:\nName is Caoimhe" : expectedRedaction, messageTextValue); } } } diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Data/TextSearchProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Data/TextSearchProviderTests.cs index a782993f6a..12bc57a3ee 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Data/TextSearchProviderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Data/TextSearchProviderTests.cs @@ -85,7 +85,8 @@ public async Task InvokingAsync_ShouldInjectFormattedResultsAsync(string? overri { SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke, ContextPrompt = overrideContextPrompt, - CitationsPrompt = overrideCitationsPrompt + CitationsPrompt = overrideCitationsPrompt, + EnableSensitiveTelemetryData = true }; var provider = new TextSearchProvider(SearchDelegateAsync, options, withLogging ? this._loggerFactoryMock.Object : null); @@ -164,6 +165,65 @@ public async Task InvokingAsync_ShouldInjectFormattedResultsAsync(string? overri } } + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task InvokingAsync_RedactsLogDataBasedOnOptionsAsync(bool enableSensitiveTelemetryData, bool useCustomRedactor) + { + // Arrange + List results = + [ + new() { SourceName = "Doc1", SourceLink = "http://example.com/doc1", Text = "Content of Doc1" } + ]; + + Task> SearchDelegateAsync(string input, CancellationToken ct) + { + return Task.FromResult>(results); + } + + var options = new TextSearchProviderOptions + { + SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke, + EnableSensitiveTelemetryData = enableSensitiveTelemetryData, + Redactor = useCustomRedactor ? new ReplacingRedactor("***") : null + }; + var provider = new TextSearchProvider(SearchDelegateAsync, options, this._loggerFactoryMock.Object); + + var invokingContext = new AIContextProvider.InvokingContext( + s_mockAgent, + new TestAgentSession(), + new AIContext { Messages = new List { new(ChatRole.User, "Sample user question?") } }); + + // Act + await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert — EnableSensitiveTelemetryData takes precedence over Redactor + var traceInvocation = this._loggerMock.Invocations + .Where(i => i.Method.Name == nameof(ILogger.Log)) + .FirstOrDefault(i => (LogLevel)i.Arguments[0]! == LogLevel.Trace); + Assert.NotNull(traceInvocation); + + var state = Assert.IsType>>(traceInvocation.Arguments[2], exactMatch: false); + var inputValue = state.First(kvp => kvp.Key == "Input").Value; + var messageTextValue = state.First(kvp => kvp.Key == "MessageText").Value; + + if (enableSensitiveTelemetryData) + { + // EnableSensitiveTelemetryData=true: raw data passes through regardless of Redactor + Assert.Equal("Sample user question?", inputValue); + Assert.Contains("Content of Doc1", messageTextValue?.ToString()!); + } + else + { + // EnableSensitiveTelemetryData=false: custom redactor or default placeholder + string expectedRedaction = useCustomRedactor ? "***" : ""; + Assert.Equal(expectedRedaction, inputValue); + Assert.Equal(expectedRedaction, messageTextValue); + } + } + [Theory] [InlineData(null, null, "Search", "Allows searching for additional information to help answer the user question.")] [InlineData("CustomSearch", "CustomDescription", "CustomSearch", "CustomDescription")] diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Memory/ChatHistoryMemoryProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Memory/ChatHistoryMemoryProviderTests.cs index 35c7f780b4..43cabebaed 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Memory/ChatHistoryMemoryProviderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Memory/ChatHistoryMemoryProviderTests.cs @@ -270,16 +270,21 @@ public async Task InvokedAsync_DoesNotThrow_WhenUpsertThrowsAsync() } [Theory] - [InlineData(false, false, 0)] - [InlineData(true, false, 0)] - [InlineData(false, true, 2)] - [InlineData(true, true, 2)] - public async Task InvokedAsync_LogsUserIdBasedOnEnableSensitiveTelemetryDataAsync(bool enableSensitiveTelemetryData, bool requestThrows, int expectedLogInvocations) + [InlineData(false, false, false, 0)] + [InlineData(false, false, true, 0)] + [InlineData(true, false, false, 0)] + [InlineData(true, false, true, 0)] + [InlineData(false, true, false, 2)] + [InlineData(false, true, true, 2)] + [InlineData(true, true, false, 2)] + [InlineData(true, true, true, 2)] + public async Task InvokedAsync_RedactsLogDataBasedOnOptionsAsync(bool enableSensitiveTelemetryData, bool requestThrows, bool useCustomRedactor, int expectedLogInvocations) { // Arrange var options = new ChatHistoryMemoryProviderOptions { - EnableSensitiveTelemetryData = enableSensitiveTelemetryData + EnableSensitiveTelemetryData = enableSensitiveTelemetryData, + Redactor = useCustomRedactor ? new ReplacingRedactor("***") : null }; if (requestThrows) @@ -309,7 +314,7 @@ public async Task InvokedAsync_LogsUserIdBasedOnEnableSensitiveTelemetryDataAsyn // Act await provider.InvokedAsync(invokedContext, CancellationToken.None); - // Assert + // Assert — EnableSensitiveTelemetryData takes precedence over Redactor Assert.Equal(expectedLogInvocations, this._loggerMock.Invocations.Count); foreach (var logInvocation in this._loggerMock.Invocations) { @@ -320,7 +325,8 @@ public async Task InvokedAsync_LogsUserIdBasedOnEnableSensitiveTelemetryDataAsyn var state = Assert.IsType>>(logInvocation.Arguments[2], exactMatch: false); var userIdValue = state.First(kvp => kvp.Key == "UserId").Value; - Assert.Equal(enableSensitiveTelemetryData ? "user1" : "", userIdValue); + string expectedRedaction = enableSensitiveTelemetryData ? "user1" : (useCustomRedactor ? "***" : ""); + Assert.Equal(expectedRedaction, userIdValue); } } @@ -526,17 +532,22 @@ public async Task InvokedAsync_CombinedFilterCanBeCompiled_WhenMultipleScopeFilt } [Theory] - [InlineData(false, false, 2)] - [InlineData(true, false, 2)] - [InlineData(false, true, 2)] - [InlineData(true, true, 2)] - public async Task InvokingAsync_LogsUserIdBasedOnEnableSensitiveTelemetryDataAsync(bool enableSensitiveTelemetryData, bool requestThrows, int expectedLogInvocations) + [InlineData(false, false, false, 2)] + [InlineData(false, false, true, 2)] + [InlineData(true, false, false, 2)] + [InlineData(true, false, true, 2)] + [InlineData(false, true, false, 2)] + [InlineData(false, true, true, 2)] + [InlineData(true, true, false, 2)] + [InlineData(true, true, true, 2)] + public async Task InvokingAsync_RedactsLogDataBasedOnOptionsAsync(bool enableSensitiveTelemetryData, bool requestThrows, bool useCustomRedactor, int expectedLogInvocations) { // Arrange var options = new ChatHistoryMemoryProviderOptions { SearchTime = ChatHistoryMemoryProviderOptions.SearchBehavior.BeforeAIInvoke, - EnableSensitiveTelemetryData = enableSensitiveTelemetryData + EnableSensitiveTelemetryData = enableSensitiveTelemetryData, + Redactor = useCustomRedactor ? new ReplacingRedactor("***") : null }; var scope = new ChatHistoryMemoryProviderScope @@ -578,7 +589,8 @@ public async Task InvokingAsync_LogsUserIdBasedOnEnableSensitiveTelemetryDataAsy // Act await provider.InvokingAsync(invokingContext, CancellationToken.None); - // Assert + // Assert — EnableSensitiveTelemetryData takes precedence over Redactor + string expectedRedaction = enableSensitiveTelemetryData ? "user1" : (useCustomRedactor ? "***" : ""); Assert.Equal(expectedLogInvocations, this._loggerMock.Invocations.Count); foreach (var logInvocation in this._loggerMock.Invocations) { @@ -589,18 +601,18 @@ public async Task InvokingAsync_LogsUserIdBasedOnEnableSensitiveTelemetryDataAsy var state = Assert.IsType>>(logInvocation.Arguments[2], exactMatch: false); var userIdValue = state.First(kvp => kvp.Key == "UserId").Value; - Assert.Equal(enableSensitiveTelemetryData ? "user1" : "", userIdValue); + Assert.Equal(expectedRedaction, userIdValue); var inputValue = state.FirstOrDefault(kvp => kvp.Key == "Input").Value; if (inputValue != null) { - Assert.Equal(enableSensitiveTelemetryData ? "Who am I?" : "", inputValue); + Assert.Equal(enableSensitiveTelemetryData ? "Who am I?" : expectedRedaction, inputValue); } var messageTextValue = state.FirstOrDefault(kvp => kvp.Key == "MessageText").Value; if (messageTextValue != null) { - Assert.Equal(enableSensitiveTelemetryData ? "## Memories\nConsider the following memories when answering user questions:\nName is Caoimhe" : "", messageTextValue); + Assert.Equal(enableSensitiveTelemetryData ? "## Memories\nConsider the following memories when answering user questions:\nName is Caoimhe" : expectedRedaction, messageTextValue); } } }