diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/ChatStrategyExtensions.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/ChatStrategyExtensions.cs new file mode 100644 index 0000000000..b7f224d751 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/ChatStrategyExtensions.cs @@ -0,0 +1,59 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Compaction; + +/// +/// Provides extension methods for . +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public static class ChatStrategyExtensions +{ + /// + /// Returns an that applies this to reduce a list of messages. + /// + /// The compaction strategy to wrap as an . + /// + /// An that, on each call to , builds a + /// from the supplied messages and applies the strategy's compaction logic, + /// returning the resulting included messages. + /// + /// + /// This allows any to be used wherever an is expected, + /// bridging the compaction pipeline into systems bound to the Microsoft.Extensions.AI contract. + /// + public static IChatReducer AsChatReducer(this CompactionStrategy strategy) + { + Throw.IfNull(strategy); + + return new CompactionStrategyChatReducer(strategy); + } + + /// + /// An adapter that delegates to a . + /// + private sealed class CompactionStrategyChatReducer : IChatReducer + { + private readonly CompactionStrategy _strategy; + + public CompactionStrategyChatReducer(CompactionStrategy strategy) + { + this._strategy = strategy; + } + + /// + public async Task> ReduceAsync(IEnumerable messages, CancellationToken cancellationToken = default) + { + CompactionMessageIndex index = CompactionMessageIndex.Create([.. messages]); + await this._strategy.CompactAsync(index, cancellationToken: cancellationToken).ConfigureAwait(false); + return index.GetIncludedMessages(); + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/ChatStrategyExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/ChatStrategyExtensionsTests.cs new file mode 100644 index 0000000000..195d5756e5 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/ChatStrategyExtensionsTests.cs @@ -0,0 +1,159 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Compaction; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.UnitTests.Compaction; + +/// +/// Contains tests for the class. +/// +public class ChatStrategyExtensionsTests +{ + [Fact] + public void AsChatReducerNullStrategyThrows() + { + // Act & Assert + Assert.Throws(() => ((CompactionStrategy)null!).AsChatReducer()); + } + + [Fact] + public void AsChatReducerReturnsIChatReducer() + { + // Arrange + ChatReducerCompactionStrategy strategy = new(new IdentityReducer(), CompactionTriggers.Always); + + // Act + IChatReducer reducer = strategy.AsChatReducer(); + + // Assert + Assert.NotNull(reducer); + } + + [Fact] + public async Task ReduceAsyncReturnsAllMessagesWhenStrategyDoesNotCompactAsync() + { + // Arrange — trigger never fires, so no compaction occurs + ChatReducerCompactionStrategy strategy = new(new IdentityReducer(), CompactionTriggers.Never); + IChatReducer reducer = strategy.AsChatReducer(); + + List messages = + [ + new(ChatRole.User, "Hello"), + new(ChatRole.Assistant, "Hi!"), + ]; + + // Act + IEnumerable result = await reducer.ReduceAsync(messages, CancellationToken.None); + + // Assert + Assert.Equal(messages, result); + } + + [Fact] + public async Task ReduceAsyncCompactsMessagesWhenStrategyFiresAsync() + { + // Arrange — reducer keeps only the last message + ChatReducerCompactionStrategy strategy = new( + new TakeLastReducer(1), + CompactionTriggers.Always); + IChatReducer reducer = strategy.AsChatReducer(); + + List messages = + [ + new(ChatRole.User, "First"), + new(ChatRole.Assistant, "Response 1"), + new(ChatRole.User, "Second"), + ]; + + // Act + IEnumerable result = await reducer.ReduceAsync(messages, CancellationToken.None); + + // Assert + List resultList = [.. result]; + Assert.Single(resultList); + Assert.Equal("Second", resultList[0].Text); + } + + [Fact] + public async Task ReduceAsyncPassesCancellationTokenToStrategyAsync() + { + // Arrange + using CancellationTokenSource cts = new(); + CancellationToken capturedToken = default; + + CapturingReducer capturingReducer = new(token => capturedToken = token); + ChatReducerCompactionStrategy strategy = new(capturingReducer, CompactionTriggers.Always); + IChatReducer reducer = strategy.AsChatReducer(); + + List messages = + [ + new(ChatRole.User, "Hello"), + new(ChatRole.User, "World"), + ]; + + // Act + await reducer.ReduceAsync(messages, cts.Token); + + // Assert + Assert.Equal(cts.Token, capturedToken); + } + + [Fact] + public async Task ReduceAsyncEmptyMessagesReturnsEmptyAsync() + { + // Arrange + ChatReducerCompactionStrategy strategy = new(new IdentityReducer(), CompactionTriggers.Always); + IChatReducer reducer = strategy.AsChatReducer(); + + // Act + IEnumerable result = await reducer.ReduceAsync([], CancellationToken.None); + + // Assert + Assert.Empty(result); + } + + /// + /// An that returns messages unchanged. + /// + private sealed class IdentityReducer : IChatReducer + { + public Task> ReduceAsync(IEnumerable messages, CancellationToken cancellationToken = default) + => Task.FromResult(messages); + } + + /// + /// An that keeps only the last n messages. + /// + private sealed class TakeLastReducer : IChatReducer + { + private readonly int _count; + + public TakeLastReducer(int count) => this._count = count; + + public Task> ReduceAsync(IEnumerable messages, CancellationToken cancellationToken = default) + => Task.FromResult(messages.Reverse().Take(this._count)); + } + + /// + /// An that captures the passed to . + /// + private sealed class CapturingReducer : IChatReducer + { + private readonly Action _capture; + + public CapturingReducer(Action capture) => this._capture = capture; + + public Task> ReduceAsync(IEnumerable messages, CancellationToken cancellationToken = default) + { + this._capture(cancellationToken); + IEnumerable reducedMessages = [messages.Reverse().First()]; + return Task.FromResult(reducedMessages); + } + } +}