From e061d095e78e84cd5c1f2955832e307e1b8e6b7d Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Mon, 2 Mar 2026 13:27:25 -0800 Subject: [PATCH 1/6] Checkpoint --- .../ChatHistoryCompactionPipeline.cs | 102 ++++++ .../ChatHistoryCompactionStrategy.cs | 119 ++++++ .../Compaction/ChatMessageGroup.cs | 64 ++++ .../Compaction/ChatMessageGroupKind.cs | 27 ++ .../Compaction/CompactionMetric.cs | 45 +++ .../Compaction/CompactionPipelineResult.cs | 49 +++ .../Compaction/CompactionResult.cs | 55 +++ .../DefaultChatHistoryMetricsCalculator.cs | 161 +++++++++ .../IChatHistoryMetricsCalculator.cs | 26 ++ .../SlidingWindowCompactionStrategy.cs | 96 +++++ .../SummarizationCompactionStrategy.cs | 166 +++++++++ .../ToolResultCompactionStrategy.cs | 113 ++++++ .../TruncationCompactionStrategy.cs | 97 +++++ .../Microsoft.Agents.AI.Abstractions.csproj | 4 + .../ChatHistoryCompactionPipelineTests.cs | 137 +++++++ .../ChatHistoryCompactionStrategyTests.cs | 172 +++++++++ .../Compaction/CompactionMetricTests.cs | 39 ++ .../CompactionPipelineResultTests.cs | 46 +++ .../Compaction/CompactionResultTests.cs | 20 + ...efaultChatHistoryMetricsCalculatorTests.cs | 341 ++++++++++++++++++ .../Internal/AgentRunContextHarness.cs | 52 +++ .../Internal/NeverCompactStrategy.cs | 26 ++ .../Compaction/Internal/NonReadOnlyList.cs | 40 ++ .../Internal/RemoveFirstMessageStrategy.cs | 36 ++ .../Compaction/MessageGroupTests.cs | 55 +++ .../SlidingWindowCompactionStrategyTests.cs | 158 ++++++++ .../SummarizationCompactionStrategyTests.cs | 190 ++++++++++ .../ToolResultCompactionStrategyTests.cs | 168 +++++++++ .../TruncationCompactionStrategyTests.cs | 130 +++++++ 29 files changed, 2734 insertions(+) create mode 100644 dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ChatHistoryCompactionPipeline.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ChatHistoryCompactionStrategy.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ChatMessageGroup.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ChatMessageGroupKind.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/CompactionMetric.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/CompactionPipelineResult.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/CompactionResult.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/DefaultChatHistoryMetricsCalculator.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/IChatHistoryMetricsCalculator.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/SlidingWindowCompactionStrategy.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/SummarizationCompactionStrategy.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ToolResultCompactionStrategy.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/TruncationCompactionStrategy.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/ChatHistoryCompactionPipelineTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/ChatHistoryCompactionStrategyTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/CompactionMetricTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/CompactionPipelineResultTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/CompactionResultTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/DefaultChatHistoryMetricsCalculatorTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/Internal/AgentRunContextHarness.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/Internal/NeverCompactStrategy.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/Internal/NonReadOnlyList.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/Internal/RemoveFirstMessageStrategy.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/MessageGroupTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/SlidingWindowCompactionStrategyTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/SummarizationCompactionStrategyTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/ToolResultCompactionStrategyTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/TruncationCompactionStrategyTests.cs diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ChatHistoryCompactionPipeline.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ChatHistoryCompactionPipeline.cs new file mode 100644 index 0000000000..451a171226 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ChatHistoryCompactionPipeline.cs @@ -0,0 +1,102 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Compaction; + +/// +/// Executes a chain of instances in order +/// against a mutable message list. +/// +/// +/// +/// Each strategy's trigger is evaluated against the metrics as they stand after prior strategies, +/// so earlier strategies can bring the conversation within thresholds that cause later strategies to skip. +/// +/// +/// The pipeline is fully standalone — it can be used without any agent, session, or context provider. +/// It also implements so it can be used directly anywhere a reducer is +/// accepted (e.g., ). +/// +/// +public class ChatHistoryCompactionPipeline : IChatReducer +{ + private readonly ChatHistoryCompactionStrategy[] _strategies; + private readonly IChatHistoryMetricsCalculator _metricsCalculator; + + /// + /// Initializes a new instance of the class. + /// + /// The ordered list of compaction strategies to execute. + /// + /// By default, is used. + /// + public ChatHistoryCompactionPipeline( + IEnumerable strategies) + : this(metricsCalculator: null, strategies) { } + + /// + /// Initializes a new instance of the class. + /// + /// + /// An optional metrics calculator. When , a + /// is used. + /// + /// The ordered list of compaction strategies to execute. + public ChatHistoryCompactionPipeline( + IChatHistoryMetricsCalculator? metricsCalculator, + params IEnumerable strategies) + { + this._strategies = Throw.IfNull(strategies).ToArray(); + this._metricsCalculator = metricsCalculator ?? DefaultChatHistoryMetricsCalculator.Instance; + } + + /// + /// Reduces the given messages by running all strategies in sequence. + /// + /// The messages to reduce. + /// The to monitor for cancellation requests. + /// The reduced set of messages. + public virtual async Task> ReduceAsync( + IEnumerable messages, + CancellationToken cancellationToken = default) + { + List messageList = messages.ToList(); // %%% HAXX + await this.CompactAsync(messageList, cancellationToken).ConfigureAwait(false); + return messageList; + } + + /// + /// Run all strategies in sequence against the given messages. + /// + /// The mutable message list to compact. + /// The to monitor for cancellation requests. + /// A with aggregate and per-strategy metrics. + public async ValueTask CompactAsync( // %%% SCOPE + IList messages, + CancellationToken cancellationToken = default) + { + Throw.IfNull(messages); + + IReadOnlyList readOnlyMessages = messages as IReadOnlyList ?? [.. messages]; // %%% TYPE CONSISTENCY + CompactionMetric overallBefore = this._metricsCalculator.Calculate(readOnlyMessages); + + List results = new(this._strategies.Length); + + foreach (ChatHistoryCompactionStrategy strategy in this._strategies) + { + CompactionResult result = await strategy.CompactAsync(messages, this._metricsCalculator, cancellationToken).ConfigureAwait(false); + results.Add(result); + } + + readOnlyMessages = messages as IReadOnlyList ?? [.. messages]; + CompactionMetric overallAfter = this._metricsCalculator.Calculate(readOnlyMessages); + + return new(overallBefore, overallAfter, results); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ChatHistoryCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ChatHistoryCompactionStrategy.cs new file mode 100644 index 0000000000..2bb8e01e40 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ChatHistoryCompactionStrategy.cs @@ -0,0 +1,119 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Compaction; + +/// +/// A named compaction strategy with an optional conditional trigger that delegates +/// actual message reduction to an . +/// +/// +/// +/// Each strategy wraps an that performs the actual compaction, +/// while the strategy adds: +/// +/// A conditional trigger via that decides whether compaction runs. +/// Before/after reporting via . +/// +/// +/// +/// For simple cases, construct a directly with any +/// . For custom trigger logic, subclass and override . +/// +/// +/// Reducers must preserve atomic message groups: an assistant message containing +/// tool calls and its corresponding tool result messages must be kept or removed together. +/// Use to identify these groups when authoring custom reducers. +/// +/// +public abstract class ChatHistoryCompactionStrategy +{ + private static readonly AsyncLocal s_currentMetrics = new(); + + /// + /// Initializes a new instance of the class. + /// + /// The that performs the actual message compaction. + protected ChatHistoryCompactionStrategy(IChatReducer reducer) + { + this.Reducer = Throw.IfNull(reducer); + } + + /// + /// Exposes the current for the executing strategy, allowing to make informed decisions. + /// + protected static CompactionMetric CurrentMetrics => s_currentMetrics.Value ?? throw new InvalidOperationException($"No active {nameof(ChatHistoryCompactionStrategy)}."); + + /// + /// Gets the that performs the actual message compaction. + /// + public IChatReducer Reducer { get; } + + /// + /// Gets the display name of this strategy, used for logging and diagnostics. + /// + /// + /// The default implementation returns the type name of the underlying . + /// + public virtual string Name => this.Reducer.GetType().Name; + + /// + /// Evaluates whether this strategy should execute given the current conversation metrics. + /// + /// The current conversation metrics. + /// + /// to proceed with compaction; to skip. + /// + public abstract bool ShouldCompact(CompactionMetric metrics); + + /// + /// Execute this strategy: check the trigger, delegate to the , and report metrics. + /// + /// The mutable message list to compact. + /// The calculator to use for metric snapshots. + /// The to monitor for cancellation requests. + /// A reporting the outcome. + public async ValueTask CompactAsync( + IList messages, + IChatHistoryMetricsCalculator metricsCalculator, + CancellationToken cancellationToken = default) + { + messages = Throw.IfNull(messages); + Throw.IfNull(metricsCalculator); + + List? messageList = messages as List; + ReadOnlyCollection snapshot = messageList is not null ? messageList.AsReadOnly() : new(messages); + CompactionMetric before = metricsCalculator.Calculate(snapshot); + s_currentMetrics.Value = before; + if (!this.ShouldCompact(before)) + { + return CompactionResult.Skipped(this.Name, before); + } + + ChatMessage[] reduced = (await this.Reducer.ReduceAsync(snapshot, cancellationToken).ConfigureAwait(false)).ToArray(); + + bool modified = reduced.Length != snapshot.Count; + if (modified) + { + messages.Clear(); + foreach (ChatMessage message in reduced) + { + messages.Add(message); + } + } + + CompactionMetric after = modified + ? metricsCalculator.Calculate(reduced) + : before; + + return new(this.Name, applied: modified, before, after); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ChatMessageGroup.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ChatMessageGroup.cs new file mode 100644 index 0000000000..e9bc36bc19 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ChatMessageGroup.cs @@ -0,0 +1,64 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; + +namespace Microsoft.Agents.AI.Compaction; + +/// +/// Represents a contiguous range of messages in a conversation that form an atomic group. +/// Atomic groups must be kept or removed together to maintain API correctness. +/// +/// +/// For example, an assistant message containing tool calls and the subsequent tool result messages +/// form an atomic group — removing one without the other causes API errors. +/// +public readonly struct ChatMessageGroup : IEquatable +{ + /// + /// Initializes a new instance of the struct. + /// + /// The zero-based index of the first message in this group. + /// The number of messages in this group. + /// The kind of this message group. + public ChatMessageGroup(int startIndex, int count, ChatMessageGroupKind kind) + { + this.StartIndex = startIndex; + this.Count = count; + this.Kind = kind; + } + + /// + /// Gets the zero-based index of the first message in this group within the original message list. + /// + public int StartIndex { get; } + + /// + /// Gets the number of messages in this group. + /// + public int Count { get; } + + /// + /// Gets the kind of this message group. + /// + public ChatMessageGroupKind Kind { get; } + + /// + public bool Equals(ChatMessageGroup other) => + this.StartIndex == other.StartIndex && + this.Count == other.Count && + this.Kind == other.Kind; + + /// + public override bool Equals(object? obj) => + obj is ChatMessageGroup other && + this.Equals(other); + + /// + public override int GetHashCode() => HashCode.Combine(this.StartIndex, this.Count, (int)this.Kind); + + /// Determines whether two instances are equal. + public static bool operator ==(ChatMessageGroup left, ChatMessageGroup right) => left.Equals(right); + + /// Determines whether two instances are not equal. + public static bool operator !=(ChatMessageGroup left, ChatMessageGroup right) => !left.Equals(right); +} diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ChatMessageGroupKind.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ChatMessageGroupKind.cs new file mode 100644 index 0000000000..8a42f88ebd --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ChatMessageGroupKind.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.Compaction; + +/// +/// Identifies the kind of an atomic message group in a conversation. +/// +public enum ChatMessageGroupKind +{ + /// A system message. + System, + + /// A user message (start of a user turn). + UserTurn, + + /// An assistant message with tool calls and their corresponding tool result messages. + AssistantToolGroup, + + /// An assistant message without tool calls. + AssistantPlain, + + /// A tool result message that is not part of a recognized group. + ToolResult, + + /// A message with an unrecognized role. + Other +} diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/CompactionMetric.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/CompactionMetric.cs new file mode 100644 index 0000000000..e4d641623b --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/CompactionMetric.cs @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; + +namespace Microsoft.Agents.AI.Compaction; + +/// +/// Immutable snapshot of conversation metrics used for compaction trigger evaluation and reporting. +/// +public sealed class CompactionMetric +{ + /// + /// Gets the estimated token count across all messages. + /// + public int TokenCount { get; init; } + + /// + /// Gets the total serialized byte count of all messages. + /// + public long ByteCount { get; init; } + +#pragma warning disable IDE0001 // Simplify Names + /// + /// Gets the total number of objects. + /// +#pragma warning restore IDE0001 // Simplify Names + public int MessageCount { get; init; } + + /// + /// Gets the number of tool/function call content items across all messages. + /// + public int ToolCallCount { get; init; } + + /// + /// Gets the number of user turns. A user turn is a user message together with the full + /// set of agent responses (including tool calls and results) before the next user input. + /// + public int UserTurnCount { get; init; } + + /// + /// Gets the atomic message group index for the analyzed messages. + /// Each group represents a contiguous range of messages that must be kept or removed together. + /// + public IReadOnlyList Groups { get; init; } = []; +} diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/CompactionPipelineResult.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/CompactionPipelineResult.cs new file mode 100644 index 0000000000..ab7e6448ae --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/CompactionPipelineResult.cs @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Linq; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Compaction; + +/// +/// Reports the aggregate outcome of a execution. +/// +public sealed class CompactionPipelineResult +{ + /// + /// Initializes a new instance of the class. + /// + /// Metrics of the conversation before any strategy ran. + /// Metrics of the conversation after all strategies ran. + /// Per-strategy results in execution order. + internal CompactionPipelineResult( + CompactionMetric before, + CompactionMetric after, + IReadOnlyList strategyResults) + { + this.Before = Throw.IfNull(before); + this.After = Throw.IfNull(after); + this.StrategyResults = Throw.IfNull(strategyResults); + } + + /// + /// Gets the conversation metrics before any compaction strategy ran. + /// + public CompactionMetric Before { get; } + + /// + /// Gets the conversation metrics after all compaction strategies ran. + /// + public CompactionMetric After { get; } + + /// + /// Gets the per-strategy results in execution order. + /// + public IReadOnlyList StrategyResults { get; } + + /// + /// Gets a value indicating whether any strategy modified the message list. + /// + public bool AnyApplied => this.StrategyResults.Any(r => r.Applied); +} diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/CompactionResult.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/CompactionResult.cs new file mode 100644 index 0000000000..c6eca6625e --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/CompactionResult.cs @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Compaction; + +/// +/// Reports the outcome of a single execution. +/// +public sealed class CompactionResult +{ + /// + /// Initializes a new instance of the class. + /// + /// The name of the strategy that produced this result. + /// Whether the strategy modified the message list. + /// Metrics before the strategy ran. + /// Metrics after the strategy ran. + public CompactionResult(string strategyName, bool applied, CompactionMetric before, CompactionMetric after) + { + this.StrategyName = Throw.IfNullOrWhitespace(strategyName); + this.Applied = applied; + this.Before = Throw.IfNull(before); + this.After = Throw.IfNull(after); + } + + /// + /// Gets the name of the strategy that produced this result. + /// + public string StrategyName { get; } + + /// + /// Gets a value indicating whether the strategy modified the message list. + /// + public bool Applied { get; } + + /// + /// Gets the conversation metrics before the strategy executed. + /// + public CompactionMetric Before { get; } + + /// + /// Gets the conversation metrics after the strategy executed. + /// + public CompactionMetric After { get; } + + /// + /// Creates a representing a skipped strategy. + /// + /// The name of the skipped strategy. + /// The current conversation metrics. + /// A result indicating no compaction was applied. + internal static CompactionResult Skipped(string strategyName, CompactionMetric metrics) + => new(strategyName, applied: false, metrics, metrics); +} diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/DefaultChatHistoryMetricsCalculator.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/DefaultChatHistoryMetricsCalculator.cs new file mode 100644 index 0000000000..19b9258584 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/DefaultChatHistoryMetricsCalculator.cs @@ -0,0 +1,161 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Linq; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Compaction; + +/// +/// Default implementation of that uses +/// JSON serialization length heuristics for token and byte estimation. +/// +/// +/// +/// Token estimation uses a configurable characters-per-token ratio (default ~4) since +/// precise tokenization requires a model-specific tokenizer. For production workloads +/// requiring accurate token counts, implement +/// with a model-appropriate tokenizer. +/// +/// +public sealed class DefaultChatHistoryMetricsCalculator : IChatHistoryMetricsCalculator +{ + /// + /// Gets the singleton instance of the chat history metrics calculator. + /// + /// + /// can be safety accessed by + /// concurrent threads. + /// + public static readonly DefaultChatHistoryMetricsCalculator Instance = new(); + + private const int DefaultCharsPerToken = 4; + private const int PerMessageOverheadTokens = 4; + + private readonly int _charsPerToken; + + /// + /// Initializes a new instance of the class. + /// + /// + /// The approximate number of characters per token used for estimation. Default is 4. + /// + public DefaultChatHistoryMetricsCalculator(int charsPerToken = DefaultCharsPerToken) + { + this._charsPerToken = charsPerToken > 0 ? charsPerToken : DefaultCharsPerToken; + } + + /// + public CompactionMetric Calculate(IReadOnlyList messages) + { + if (messages is null || messages.Count == 0) + { + return new(); + } + + int totalTokens = 0; + long totalBytes = 0; + int toolCallCount = 0; + int userTurnCount = 0; + bool inUserTurn = false; + List groups = []; + int index = 0; + + while (index < messages.Count) + { + ChatMessage message = messages[index]; + + // Accumulate per-message metrics + this.AccumulateMessageMetrics(message, ref totalTokens, ref totalBytes, ref toolCallCount); + + if (message.Role == ChatRole.User) + { + if (!inUserTurn) + { + userTurnCount++; + inUserTurn = true; + } + } + else + { + inUserTurn = false; + } + + // Identify the group starting at this message + if (message.Role == ChatRole.System) + { + groups.Add(new(index, 1, ChatMessageGroupKind.System)); + index++; + } + else if (message.Role == ChatRole.User) + { + groups.Add(new(index, 1, ChatMessageGroupKind.UserTurn)); + index++; + } + else if (message.Role == ChatRole.Assistant) + { + bool hasToolCalls = message.Contents!.Any(c => c is FunctionCallContent); + + if (hasToolCalls) + { + int groupStart = index; + index++; + + while (index < messages.Count && messages[index].Role == ChatRole.Tool) + { + this.AccumulateMessageMetrics(messages[index], ref totalTokens, ref totalBytes, ref toolCallCount); + inUserTurn = false; + index++; + } + + groups.Add(new(groupStart, index - groupStart, ChatMessageGroupKind.AssistantToolGroup)); + } + else + { + groups.Add(new(index, 1, ChatMessageGroupKind.AssistantPlain)); + index++; + } + } + else if (message.Role == ChatRole.Tool) + { + groups.Add(new(index, 1, ChatMessageGroupKind.ToolResult)); + index++; + } + else + { + groups.Add(new(index, 1, ChatMessageGroupKind.Other)); + index++; + } + } + + return new() + { + TokenCount = totalTokens, + ByteCount = totalBytes, + MessageCount = messages.Count, + ToolCallCount = toolCallCount, + UserTurnCount = userTurnCount, + Groups = groups + }; + } + + private void AccumulateMessageMetrics(ChatMessage message, ref int totalTokens, ref long totalBytes, ref int toolCallCount) + { + string serialized = message.Text; + + int charCount = serialized.Length; + totalBytes += System.Text.Encoding.UTF8.GetByteCount(serialized); + totalTokens += (charCount / this._charsPerToken) + PerMessageOverheadTokens; + + if (message.Contents is not null) + { + foreach (AIContent content in message.Contents) + { + if (content is FunctionCallContent) + { + toolCallCount++; + } + } + } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/IChatHistoryMetricsCalculator.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/IChatHistoryMetricsCalculator.cs new file mode 100644 index 0000000000..830268dc76 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/IChatHistoryMetricsCalculator.cs @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Compaction; + +// %%% TODO: Is this interface needed? Consider whether the default implementation is sufficient +// and whether custom metrics calculators are a realistic extension point. + +/// +/// Computes for a list of messages. +/// +/// +/// Token counting is model-specific. Implementations can provide precise tokenization +/// (e.g., using tiktoken or a model-specific tokenizer) or use estimation heuristics. +/// +public interface IChatHistoryMetricsCalculator // %%% NEEDED ??? +{ + /// + /// Compute metrics for the given messages. + /// + /// The messages to analyze. + /// A snapshot. + CompactionMetric Calculate(IReadOnlyList messages); +} diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/SlidingWindowCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/SlidingWindowCompactionStrategy.cs new file mode 100644 index 0000000000..9d8f12fc62 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/SlidingWindowCompactionStrategy.cs @@ -0,0 +1,96 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Compaction; + +/// +/// A compaction strategy that keeps only the most recent user turns and their +/// associated response groups, removing older turns to bound conversation length. +/// +/// +/// +/// This strategy always preserves system messages. It identifies user turns in the +/// conversation and keeps the last maxTurns turns along with all response groups +/// (assistant replies, tool call groups) that follow each kept turn. +/// +/// +/// The trigger condition fires only when the number of user turns exceeds maxTurns. +/// +/// +/// This strategy is more predictable than token-based truncation for bounding conversation +/// length, since it operates on logical turn boundaries rather than estimated token counts. +/// +/// +public class SlidingWindowCompactionStrategy : ChatHistoryCompactionStrategy +{ + private readonly int _maxTurns; + + /// + /// Initializes a new instance of the class. + /// + /// + /// The maximum number of user turns to keep. Older turns and their associated responses are removed. + /// + public SlidingWindowCompactionStrategy(int maxTurns) + : base(new SlidingWindowReducer(maxTurns)) + { + this._maxTurns = maxTurns; + } + + /// + public override bool ShouldCompact(CompactionMetric metrics) => + metrics.UserTurnCount > this._maxTurns; + + /// + /// An that keeps system messages and the last N user turns + /// with all their associated response groups. + /// + private sealed class SlidingWindowReducer(int maxTurns) : IChatReducer + { + public Task> ReduceAsync( + IEnumerable messages, + CancellationToken cancellationToken = default) + { + IReadOnlyList messageList = [.. messages]; + IReadOnlyList groups = CurrentMetrics.Groups; + + // Find the group-list indices where each user turn starts + //int[] turnGroupIndices = groups.Where(group => group.Kind == ChatMessageGroupKind.UserTurn).Select(group => group.StartIndex).ToArray(); // %%% TODO + List turnGroupIndices = []; + for (int i = 0; i < groups.Count; i++) + { + if (groups[i].Kind == ChatMessageGroupKind.UserTurn) + { + turnGroupIndices.Add(i); + } + } + + // Keep the last maxTurns user turns and everything after the first kept turn + int firstKeptTurnIndex = turnGroupIndices.Count - maxTurns; + int firstKeptGroupIndex = turnGroupIndices[firstKeptTurnIndex]; + + List result = new(messageList.Count); + for (int gi = 0; gi < groups.Count; gi++) + { + ChatMessageGroup group = groups[gi]; + + // Always keep system messages; keep groups at or after the window start + if (group.Kind == ChatMessageGroupKind.System || gi >= firstKeptGroupIndex) + { + for (int j = group.StartIndex; j < group.StartIndex + group.Count; j++) + { + result.Add(messageList[j]); + } + } + } + + return Task.FromResult>(result); + } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/SummarizationCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/SummarizationCompactionStrategy.cs new file mode 100644 index 0000000000..433b7c0f04 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/SummarizationCompactionStrategy.cs @@ -0,0 +1,166 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Compaction; + +/// +/// A compaction strategy that uses an LLM to summarize older portions of the conversation, +/// replacing them with a concise summary message that preserves key facts and context. +/// +/// +/// +/// This strategy sits between tool-result clearing (gentle) and truncation (aggressive) in the +/// compaction ladder. Unlike truncation which discards messages entirely, summarization preserves +/// the essential information in compressed form, allowing the agent to maintain awareness of +/// earlier context. +/// +/// +/// The strategy protects system messages and the most recent preserveRecentGroups +/// non-system groups. All older groups are collected and sent to the +/// for summarization. The resulting summary replaces those messages as a single assistant message. +/// +/// +public class SummarizationCompactionStrategy : ChatHistoryCompactionStrategy +{ + private readonly int _maxTokens; + + /// + /// The default summarization prompt used when none is provided. + /// + public const string DefaultSummarizationPrompt = + """ + You are a conversation summarizer. Produce a concise summary of the conversation that preserves: + + - Key facts, decisions, and user preferences + - Important context needed for future turns + - Tool call outcomes and their significance + + Omit pleasantries and redundant exchanges. Be factual and brief. + """; + + /// + /// Initializes a new instance of the class. + /// + /// The to use for generating summaries. A smaller, faster model is recommended. + /// The maximum token budget. Summarization is triggered when the token count exceeds this value. + /// + /// The number of most-recent non-system message groups to protect from summarization. + /// Defaults to 4, preserving the current and recent exchanges. + /// + /// + /// An optional custom system prompt for the summarization LLM call. When , + /// a default prompt that emphasizes fact-preservation is used. + /// + public SummarizationCompactionStrategy( + IChatClient chatClient, + int maxTokens, + int preserveRecentGroups = 4, + string? summarizationPrompt = null) + : base(new SummarizationReducer(chatClient, preserveRecentGroups, summarizationPrompt ?? DefaultSummarizationPrompt)) + { + this._maxTokens = maxTokens; + } + + /// + public override bool ShouldCompact(CompactionMetric metrics) => + metrics.TokenCount > this._maxTokens; + + /// + /// An that sends older message groups to an LLM for summarization, + /// then replaces them with a single summary message. + /// + private sealed class SummarizationReducer : IChatReducer + { + private readonly IChatClient _chatClient; + private readonly int _preserveRecentGroups; + private readonly string _summarizationPrompt; + + public SummarizationReducer(IChatClient chatClient, int preserveRecentGroups, string summarizationPrompt) + { + this._chatClient = Throw.IfNull(chatClient); + this._preserveRecentGroups = preserveRecentGroups; + this._summarizationPrompt = Throw.IfNullOrEmpty(summarizationPrompt); + } + + public async Task> ReduceAsync( + IEnumerable messages, + CancellationToken cancellationToken = default) + { + IReadOnlyList messageList = [.. messages]; + IReadOnlyList groups = CurrentMetrics.Groups; + + List nonSystemGroups = groups.Where(g => g.Kind != ChatMessageGroupKind.System).ToList(); + int protectedFromIndex = Math.Max(0, nonSystemGroups.Count - this._preserveRecentGroups); + + if (protectedFromIndex == 0) + { + // Nothing to summarize — all groups are protected + return messageList; + } + + // Collect messages from groups that will be summarized + List toSummarize = []; + for (int i = 0; i < protectedFromIndex; i++) + { + ChatMessageGroup group = nonSystemGroups[i]; + for (int j = group.StartIndex; j < group.StartIndex + group.Count; j++) + { + toSummarize.Add(messageList[j]); + } + } + + if (toSummarize.Count == 0) + { + return messageList; + } + + // Build the summarization request + List summarizationRequest = + [ + new(ChatRole.System, this._summarizationPrompt), + .. toSummarize, + new(ChatRole.User, "Summarize the conversation above concisely."), + ]; + + ChatResponse response = await this._chatClient.GetResponseAsync(summarizationRequest, cancellationToken: cancellationToken).ConfigureAwait(false); + string summaryText = string.IsNullOrWhiteSpace(response.Text) ? "[Summary unavailable]" : response.Text; + + // Build result: system groups + summary + protected groups + List result = []; + + // Keep system messages + foreach (ChatMessageGroup group in groups) + { + if (group.Kind == ChatMessageGroupKind.System) + { + for (int j = group.StartIndex; j < group.StartIndex + group.Count; j++) + { + result.Add(messageList[j]); + } + } + } + + // Insert summary + result.Add(new ChatMessage(ChatRole.Assistant, $"[Summary]\n{summaryText}")); + + // Keep protected groups + for (int i = protectedFromIndex; i < nonSystemGroups.Count; i++) + { + ChatMessageGroup group = nonSystemGroups[i]; + for (int j = group.StartIndex; j < group.StartIndex + group.Count; j++) + { + result.Add(messageList[j]); + } + } + + return result; + } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ToolResultCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ToolResultCompactionStrategy.cs new file mode 100644 index 0000000000..424943262b --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ToolResultCompactionStrategy.cs @@ -0,0 +1,113 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Compaction; + +/// +/// A compaction strategy that collapses old assistant-tool-call groups into single +/// concise assistant messages, removing the detailed tool results while preserving +/// a record of which tools were called. +/// +/// +/// +/// This is the gentlest compaction strategy — it does not remove any user messages or +/// plain assistant responses. It only targets +/// entries outside the protected recent window, replacing each multi-message group +/// (assistant call + tool results) with a single assistant message like +/// [Tool calls: get_weather, search_docs]. +/// +/// +/// The trigger condition fires only when token count exceeds maxTokens and +/// there is at least one tool call in the conversation. +/// +/// +public class ToolResultCompactionStrategy : ChatHistoryCompactionStrategy +{ + private readonly int _maxTokens; + + /// + /// Initializes a new instance of the class. + /// + /// The maximum token budget. Tool groups are collapsed when the token count exceeds this value. + /// + /// The number of most-recent non-system message groups to protect from collapsing. + /// Defaults to 2, ensuring the current turn's tool interactions remain visible. + /// + public ToolResultCompactionStrategy(int maxTokens, int preserveRecentGroups = 2) + : base(new ToolResultClearingReducer(preserveRecentGroups)) + { + this._maxTokens = maxTokens; + } + + /// + public override bool ShouldCompact(CompactionMetric metrics) => + metrics.TokenCount > this._maxTokens && metrics.ToolCallCount > 0; + + /// + /// An that collapses + /// entries into single summary messages, preserving the most recent groups. + /// + private sealed class ToolResultClearingReducer(int preserveRecentGroups) : IChatReducer + { + public Task> ReduceAsync( + IEnumerable messages, + CancellationToken cancellationToken = default) + { + IReadOnlyList messageList = [.. messages]; + IReadOnlyList groups = CurrentMetrics.Groups; + + List nonSystemGroups = groups.Where(g => g.Kind != ChatMessageGroupKind.System).ToList(); + int protectedFromIndex = Math.Max(0, nonSystemGroups.Count - preserveRecentGroups); + HashSet protectedGroupStarts = []; + for (int i = protectedFromIndex; i < nonSystemGroups.Count; i++) + { + protectedGroupStarts.Add(nonSystemGroups[i].StartIndex); + } + + List result = new(messageList.Count); + bool anyCollapsed = false; + + foreach (ChatMessageGroup group in groups) + { + if (group.Kind == ChatMessageGroupKind.AssistantToolGroup && !protectedGroupStarts.Contains(group.StartIndex)) + { + // Collapse this tool group into a single summary message + List toolNames = []; + for (int j = group.StartIndex; j < group.StartIndex + group.Count; j++) + { + if (messageList[j].Contents is not null) + { + foreach (AIContent content in messageList[j].Contents) + { + if (content is FunctionCallContent fcc) + { + toolNames.Add(fcc.Name); + } + } + } + } + + string summary = $"[Tool calls: {string.Join(", ", toolNames)}]"; + result.Add(new ChatMessage(ChatRole.Assistant, summary)); + anyCollapsed = true; + } + else + { + // Keep this group as-is + for (int j = group.StartIndex; j < group.StartIndex + group.Count; j++) + { + result.Add(messageList[j]); + } + } + } + + return Task.FromResult>(anyCollapsed ? result : messageList); + } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/TruncationCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/TruncationCompactionStrategy.cs new file mode 100644 index 0000000000..1390f463fc --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/TruncationCompactionStrategy.cs @@ -0,0 +1,97 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Compaction; + +/// +/// A compaction strategy that removes the oldest message groups until the estimated +/// token count is within a specified budget. +/// +/// +/// +/// This strategy preserves system messages and removes the oldest non-system message groups first. +/// It respects atomic group boundaries — an assistant message with tool calls and its +/// corresponding tool result messages are always removed together. +/// +/// +/// The trigger condition fires only when the current token count exceeds maxTokens. +/// +/// +public class TruncationCompactionStrategy : ChatHistoryCompactionStrategy +{ + private readonly int _maxTokens; + + /// + /// Initializes a new instance of the class. + /// + /// The maximum token budget. Groups are removed until the token count is at or below this value. + /// + /// The minimum number of most-recent non-system message groups to keep. + /// Defaults to 1 so that at least the latest exchange is always preserved. + /// + public TruncationCompactionStrategy(int maxTokens, int preserveRecentGroups = 1) + : base(new TruncationReducer(preserveRecentGroups)) + { + this._maxTokens = maxTokens; + } + + /// + public override bool ShouldCompact(CompactionMetric metrics) => + metrics.TokenCount > this._maxTokens; + + /// + /// An that removes the oldest non-system message groups, + /// keeping at least the most recent group. + /// + private sealed class TruncationReducer(int preserveRecentGroups) : IChatReducer + { + public Task> ReduceAsync( + IEnumerable messages, + CancellationToken cancellationToken = default) + { + IReadOnlyList messageList = [.. messages]; + + List removableGroups = CurrentMetrics.Groups.Where(g => g.Kind != ChatMessageGroupKind.System).ToList(); + + if (removableGroups.Count == 0) + { + return Task.FromResult>(messageList); + } + + // Remove oldest non-system groups, keeping at least preserveRecentGroups. + int maxRemovable = removableGroups.Count - preserveRecentGroups; + + if (maxRemovable <= 0) + { + return Task.FromResult>(messageList); + } + + HashSet removedGroupStarts = []; + for (int ri = 0; ri < maxRemovable; ri++) + { + removedGroupStarts.Add(removableGroups[ri].StartIndex); + } + + List messagesToKeep = new(messageList.Count); + foreach (ChatMessageGroup group in CurrentMetrics.Groups) + { + if (removedGroupStarts.Contains(group.StartIndex)) + { + continue; + } + + for (int j = group.StartIndex; j < group.StartIndex + group.Count; j++) + { + messagesToKeep.Add(messageList[j]); + } + } + + return Task.FromResult>(messagesToKeep); + } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Microsoft.Agents.AI.Abstractions.csproj b/dotnet/src/Microsoft.Agents.AI.Abstractions/Microsoft.Agents.AI.Abstractions.csproj index e31093e174..01fd5db648 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/Microsoft.Agents.AI.Abstractions.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Microsoft.Agents.AI.Abstractions.csproj @@ -31,6 +31,10 @@ + + + + diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/ChatHistoryCompactionPipelineTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/ChatHistoryCompactionPipelineTests.cs new file mode 100644 index 0000000000..d5d58d7ba9 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/ChatHistoryCompactionPipelineTests.cs @@ -0,0 +1,137 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Abstractions.UnitTests.Compaction.Internal; +using Microsoft.Agents.AI.Compaction; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction; + +public class ChatHistoryCompactionPipelineTests +{ + [Fact] + public async Task EmptyStrategies_ReturnsUnmodifiedAsync() + { + ChatHistoryCompactionPipeline pipeline = new([]); + List messages = [new(ChatRole.User, "Hello")]; + + CompactionPipelineResult result = await pipeline.CompactAsync(messages); + + Assert.False(result.AnyApplied); + Assert.Equal(1, result.Before.MessageCount); + Assert.Equal(1, result.After.MessageCount); + Assert.Empty(result.StrategyResults); + } + + [Fact] + public async Task ChainsStrategies_InOrderAsync() + { + ChatHistoryCompactionStrategy[] strategies = + [ + new NeverCompactStrategy(), + new RemoveFirstMessageStrategy(), + ]; + ChatHistoryCompactionPipeline pipeline = new(strategies); + List messages = + [ + new(ChatRole.User, "First"), + new(ChatRole.User, "Second"), + ]; + + CompactionPipelineResult result = await pipeline.CompactAsync(messages); + + Assert.True(result.AnyApplied); + Assert.Equal(2, result.StrategyResults.Count); + Assert.False(result.StrategyResults[0].Applied); + Assert.True(result.StrategyResults[1].Applied); + Assert.Single(messages); + } + + [Fact] + public async Task ReportsOverallMetricsAsync() + { + ChatHistoryCompactionPipeline pipeline = new([new RemoveFirstMessageStrategy()]); + List messages = + [ + new(ChatRole.User, "First"), + new(ChatRole.User, "Second"), + new(ChatRole.User, "Third"), + ]; + + CompactionPipelineResult result = await pipeline.CompactAsync(messages); + + Assert.Equal(3, result.Before.MessageCount); + Assert.Equal(2, result.After.MessageCount); + } + + [Fact] + public async Task CustomMetricsCalculator_IsUsedAsync() + { + Moq.Mock calcMock = new(); + calcMock + .Setup(c => c.Calculate(Moq.It.IsAny>())) + .Returns(new CompactionMetric { MessageCount = 42 }); + + ChatHistoryCompactionPipeline pipeline = new(calcMock.Object, []); + List messages = [new(ChatRole.User, "Hello")]; + + CompactionPipelineResult result = await pipeline.CompactAsync(messages); + + Assert.Equal(42, result.Before.MessageCount); + calcMock.Verify(c => c.Calculate(Moq.It.IsAny>()), Moq.Times.AtLeast(2)); + } + + [Fact] + public async Task CompactAsync_NonReadOnlyListMessages_WorksAsync() + { + ChatHistoryCompactionPipeline pipeline = new([new RemoveFirstMessageStrategy()]); + NonReadOnlyList messages = new( + [ + new(ChatRole.User, "First"), + new(ChatRole.User, "Second"), + ]); + + CompactionPipelineResult result = await pipeline.CompactAsync(messages); + + Assert.True(result.AnyApplied); + Assert.Single(messages); + } + + [Fact] + public async Task ReduceAsync_DelegatesCompactionAsync() + { + ChatHistoryCompactionPipeline pipeline = new([new RemoveFirstMessageStrategy()]); + + ChatMessage[] messages = + [ + new(ChatRole.User, "First"), + new(ChatRole.User, "Second"), + new(ChatRole.User, "Third"), + ]; + + IEnumerable result = await ((IChatReducer)pipeline).ReduceAsync(messages, default); + List resultList = result.ToList(); + + Assert.Equal(2, resultList.Count); + Assert.Equal("Second", resultList[0].Text); + Assert.Equal("Third", resultList[1].Text); + } + + [Fact] + public async Task ReduceAsync_EmptyStrategies_ReturnsAllMessagesAsync() + { + ChatHistoryCompactionPipeline pipeline = new([]); + + ChatMessage[] messages = + [ + new(ChatRole.User, "Hello"), + new(ChatRole.User, "World"), + ]; + + IEnumerable result = await ((IChatReducer)pipeline).ReduceAsync(messages, default); + + Assert.Equal(2, result.Count()); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/ChatHistoryCompactionStrategyTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/ChatHistoryCompactionStrategyTests.cs new file mode 100644 index 0000000000..5c2e006350 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/ChatHistoryCompactionStrategyTests.cs @@ -0,0 +1,172 @@ +// 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.Abstractions.UnitTests.Compaction.Internal; +using Microsoft.Agents.AI.Compaction; +using Microsoft.Extensions.AI; +using Moq; + +namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction; + +public class ChatHistoryCompactionStrategyTests +{ + [Fact] + public async Task ShouldCompactReturnsFalse_SkipsAsync() + { + NeverCompactStrategy strategy = new(); + List messages = [new(ChatRole.User, "Hello")]; + DefaultChatHistoryMetricsCalculator calculator = new(); + + CompactionResult result = await strategy.CompactAsync(messages, calculator); + + Assert.False(result.Applied); + } + + [Fact] + public async Task ShouldCompactReturnsTrue_RunsCompactionAsync() + { + RemoveFirstMessageStrategy strategy = new(); + List messages = + [ + new(ChatRole.User, "First"), + new(ChatRole.User, "Second"), + ]; + DefaultChatHistoryMetricsCalculator calculator = new(); + + CompactionResult result = await strategy.CompactAsync(messages, calculator); + + Assert.True(result.Applied); + Assert.Single(messages); + Assert.Equal("Second", messages[0].Text); + Assert.Equal(2, result.Before.MessageCount); + Assert.Equal(1, result.After.MessageCount); + } + + [Fact] + public async Task DelegatesToIChatReducerAsync() + { + Mock reducerMock = new(); + reducerMock + .Setup(r => r.ReduceAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync((IEnumerable msgs, CancellationToken _) => msgs.Skip(1)); + + TestCompactionStrategy strategy = new(reducerMock.Object); + List messages = + [ + new(ChatRole.User, "First"), + new(ChatRole.User, "Second"), + ]; + DefaultChatHistoryMetricsCalculator calculator = new(); + + CompactionResult result = await strategy.CompactAsync(messages, calculator); + + Assert.True(result.Applied); + Assert.Single(messages); + Assert.Equal("Second", messages[0].Text); + reducerMock.Verify(r => r.ReduceAsync(It.IsAny>(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task ReducerNoChange_ReturnsFalseAsync() + { + Mock reducerMock = new(); + reducerMock + .Setup(r => r.ReduceAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync((IEnumerable msgs, CancellationToken _) => msgs); + + TestCompactionStrategy strategy = new(reducerMock.Object); + List messages = + [ + new(ChatRole.User, "Hello"), + ]; + DefaultChatHistoryMetricsCalculator calculator = new(); + + CompactionResult result = await strategy.CompactAsync(messages, calculator); + + Assert.False(result.Applied); + Assert.Single(messages); + } + + [Fact] + public void ExposesReducer() + { + Mock reducerMock = new(); + TestCompactionStrategy strategy = new(reducerMock.Object); + + Assert.Same(reducerMock.Object, strategy.Reducer); + } + + [Fact] + public void DefaultName_IsReducerTypeName() + { + Mock reducerMock = new(); + TestCompactionStrategy strategy = new(reducerMock.Object); + + // Moq proxy type name is used since we're using a mock + Assert.NotNull(strategy.Name); + Assert.NotEmpty(strategy.Name); + } + + [Fact] + public void ConditionDelegate_ReturnsTrue_ShouldCompactReturnsTrue() + { + Mock reducerMock = new(); + TestCompactionStrategy strategy = new(reducerMock.Object); + + CompactionMetric metrics = new() { TokenCount = 100 }; + Assert.True(strategy.ShouldCompact(metrics)); + } + + [Fact] + public void ConditionDelegate_ReturnsFalse_ShouldCompactReturnsFalse() + { + Mock reducerMock = new(); + TestCompactionStrategy strategy = new(reducerMock.Object, shouldCompact: false); + + CompactionMetric metrics = new() { TokenCount = 100 }; + Assert.False(strategy.ShouldCompact(metrics)); + } + + [Fact] + public async Task CompactAsync_NonReadOnlyListMessages_WorksAsync() + { + RemoveFirstMessageStrategy strategy = new(); + NonReadOnlyList messages = new( + [ + new(ChatRole.User, "First"), + new(ChatRole.User, "Second"), + ]); + DefaultChatHistoryMetricsCalculator calculator = new(); + + CompactionResult result = await strategy.CompactAsync(messages, calculator); + + Assert.True(result.Applied); + Assert.Single(messages); + Assert.Equal("Second", messages[0].Text); + } + + [Fact] + public void CurrentMetrics_OutsideStrategy_Throws() + { + Assert.Throws(() => TestCompactionStrategy.GetCurrentMetrics()); + } + + private sealed class TestCompactionStrategy : ChatHistoryCompactionStrategy + { + private readonly bool _shouldCompact; + + public TestCompactionStrategy(IChatReducer reducer, bool shouldCompact = true) + : base(reducer) + { + this._shouldCompact = shouldCompact; + } + + public override bool ShouldCompact(CompactionMetric metrics) => this._shouldCompact; + + public static CompactionMetric GetCurrentMetrics() => CurrentMetrics; + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/CompactionMetricTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/CompactionMetricTests.cs new file mode 100644 index 0000000000..c2287512cd --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/CompactionMetricTests.cs @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI.Compaction; + +namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction; + +public class CompactionMetricTests +{ + [Fact] + public void DefaultValues_AreZero() + { + CompactionMetric metrics = new(); + Assert.Equal(0, metrics.TokenCount); + Assert.Equal(0L, metrics.ByteCount); + Assert.Equal(0, metrics.MessageCount); + Assert.Equal(0, metrics.ToolCallCount); + Assert.Equal(0, metrics.UserTurnCount); + Assert.Empty(metrics.Groups); + } + + [Fact] + public void InitProperties_SetCorrectly() + { + CompactionMetric metrics = new() + { + TokenCount = 100, + ByteCount = 500, + MessageCount = 5, + ToolCallCount = 2, + UserTurnCount = 3 + }; + + Assert.Equal(100, metrics.TokenCount); + Assert.Equal(500L, metrics.ByteCount); + Assert.Equal(5, metrics.MessageCount); + Assert.Equal(2, metrics.ToolCallCount); + Assert.Equal(3, metrics.UserTurnCount); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/CompactionPipelineResultTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/CompactionPipelineResultTests.cs new file mode 100644 index 0000000000..6921bc26ca --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/CompactionPipelineResultTests.cs @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; + +using Microsoft.Agents.AI.Compaction; + +namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction; + +public class CompactionPipelineResultTests +{ + [Fact] + public void Properties_AreReadable() + { + CompactionMetric before = new() { MessageCount = 10 }; + CompactionMetric after = new() { MessageCount = 5 }; + CompactionResult strategyResult = new("Test", applied: true, before, after); + List results = [strategyResult]; + + CompactionPipelineResult pipelineResult = new(before, after, results); + + Assert.Same(before, pipelineResult.Before); + Assert.Same(after, pipelineResult.After); + Assert.Single(pipelineResult.StrategyResults); + } + + [Fact] + public void AnyApplied_AllFalse_ReturnsFalse() + { + CompactionMetric metrics = new() { MessageCount = 5 }; + CompactionResult skipped = CompactionResult.Skipped("Skip", metrics); + CompactionPipelineResult result = new(metrics, metrics, [skipped]); + + Assert.False(result.AnyApplied); + } + + [Fact] + public void AnyApplied_SomeTrue_ReturnsTrue() + { + CompactionMetric before = new() { MessageCount = 10 }; + CompactionMetric after = new() { MessageCount = 5 }; + CompactionResult applied = new("Applied", applied: true, before, after); + CompactionPipelineResult result = new(before, after, [applied]); + + Assert.True(result.AnyApplied); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/CompactionResultTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/CompactionResultTests.cs new file mode 100644 index 0000000000..1bb476786b --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/CompactionResultTests.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI.Compaction; + +namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction; + +public class CompactionResultTests +{ + [Fact] + public void Skipped_HasSameBeforeAndAfter() + { + CompactionMetric metrics = new() { MessageCount = 5, TokenCount = 100 }; + CompactionResult result = CompactionResult.Skipped("Test", metrics); + + Assert.Equal("Test", result.StrategyName); + Assert.False(result.Applied); + Assert.Same(metrics, result.Before); + Assert.Same(metrics, result.After); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/DefaultChatHistoryMetricsCalculatorTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/DefaultChatHistoryMetricsCalculatorTests.cs new file mode 100644 index 0000000000..01387f1eed --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/DefaultChatHistoryMetricsCalculatorTests.cs @@ -0,0 +1,341 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using Microsoft.Agents.AI.Compaction; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction; + +public class DefaultChatHistoryMetricsCalculatorTests +{ + [Fact] + public void EmptyList_ReturnsZeroMetrics() + { + DefaultChatHistoryMetricsCalculator calculator = new(); + CompactionMetric metrics = calculator.Calculate([]); + + Assert.Equal(0, metrics.TokenCount); + Assert.Equal(0L, metrics.ByteCount); + Assert.Equal(0, metrics.MessageCount); + Assert.Equal(0, metrics.ToolCallCount); + Assert.Equal(0, metrics.UserTurnCount); + } + + [Fact] + public void CountsMessages() + { + DefaultChatHistoryMetricsCalculator calculator = new(); + List messages = + [ + new(ChatRole.User, "Hello"), + new(ChatRole.Assistant, "Hi there"), + ]; + + CompactionMetric metrics = calculator.Calculate(messages); + + Assert.Equal(2, metrics.MessageCount); + } + + [Fact] + public void CountsUserTurns() + { + DefaultChatHistoryMetricsCalculator calculator = new(); + List messages = + [ + new(ChatRole.User, "Hello"), + new(ChatRole.Assistant, "Hi"), + new(ChatRole.User, "How are you?"), + new(ChatRole.Assistant, "Good"), + ]; + + CompactionMetric metrics = calculator.Calculate(messages); + + Assert.Equal(2, metrics.UserTurnCount); + } + + [Fact] + public void CountsToolCalls() + { + DefaultChatHistoryMetricsCalculator calculator = new(); + ChatMessage assistantMsg = new(ChatRole.Assistant, [ + new FunctionCallContent("call1", "get_weather", new Dictionary { ["city"] = "NYC" }), + new FunctionCallContent("call2", "get_time"), + ]); + List messages = + [ + new(ChatRole.User, "What's the weather?"), + assistantMsg, + ]; + + CompactionMetric metrics = calculator.Calculate(messages); + + Assert.Equal(2, metrics.ToolCallCount); + } + + [Fact] + public void ConsecutiveUserMessages_CountAsOneTurn() + { + DefaultChatHistoryMetricsCalculator calculator = new(); + List messages = + [ + new(ChatRole.User, "First"), + new(ChatRole.User, "Second"), + new(ChatRole.Assistant, "Reply"), + ]; + + CompactionMetric metrics = calculator.Calculate(messages); + + Assert.Equal(1, metrics.UserTurnCount); + } + + [Fact] + public void TokenCount_IsPositive() + { + DefaultChatHistoryMetricsCalculator calculator = new(); + List messages = + [ + new(ChatRole.User, "Hello world"), + ]; + + CompactionMetric metrics = calculator.Calculate(messages); + + Assert.True(metrics.TokenCount > 0); + Assert.True(metrics.ByteCount > 0); + } + + [Fact] + public void NullInput_ReturnsZeroMetrics() + { + DefaultChatHistoryMetricsCalculator calculator = new(); + CompactionMetric metrics = calculator.Calculate(null!); + + Assert.Equal(0, metrics.TokenCount); + Assert.Equal(0L, metrics.ByteCount); + Assert.Equal(0, metrics.MessageCount); + Assert.Empty(metrics.Groups); + } + + [Fact] + public void InvalidCharsPerToken_UsesDefault() + { + // A non-positive charsPerToken should fall back to the default (4) + DefaultChatHistoryMetricsCalculator calculator = new(charsPerToken: 0); + List messages = + [ + new(ChatRole.User, "Hello world"), + ]; + + CompactionMetric metrics = calculator.Calculate(messages); + + // With default 4 chars/token: "Hello world" = 11 chars → 11/4=2 + 4 overhead = 6 tokens + Assert.True(metrics.TokenCount > 0); + } + + [Fact] + public void NullMessageText_HandledGracefully() + { + DefaultChatHistoryMetricsCalculator calculator = new(); + // Message with no text content — Text returns null + ChatMessage msg = new() { Role = ChatRole.User }; + List messages = [msg]; + + CompactionMetric metrics = calculator.Calculate(messages); + + Assert.Equal(1, metrics.MessageCount); + // Null text → empty string → 0 bytes, only overhead tokens + Assert.True(metrics.TokenCount > 0); // per-message overhead + Assert.Equal(0L, metrics.ByteCount); + } + + [Fact] + public void NullContents_SkipsToolCounting() + { + DefaultChatHistoryMetricsCalculator calculator = new(); + ChatMessage msg = new(ChatRole.User, "text"); + msg.Contents = null!; + List messages = [msg]; + + CompactionMetric metrics = calculator.Calculate(messages); + + Assert.Equal(1, metrics.MessageCount); + Assert.Equal(0, metrics.ToolCallCount); + } + + [Fact] + public void MessageWithOnlyNonTextContent_NullTextHandled() + { + DefaultChatHistoryMetricsCalculator calculator = new(); + // FunctionCallContent-only message has null Text + ChatMessage msg = new(ChatRole.Assistant, + [ + new FunctionCallContent("c1", "func"), + ]); + List messages = [msg]; + + CompactionMetric metrics = calculator.Calculate(messages); + + Assert.Equal(1, metrics.MessageCount); + Assert.Equal(1, metrics.ToolCallCount); + } + + [Fact] + public void Calculate_PopulatesGroupIndex() + { + DefaultChatHistoryMetricsCalculator calculator = new(); + List messages = + [ + new(ChatRole.System, "System prompt"), + new(ChatRole.User, "Hello"), + new(ChatRole.Assistant, "Hi there"), + ]; + + CompactionMetric metrics = calculator.Calculate(messages); + + Assert.Equal(3, metrics.Groups.Count); + Assert.Equal(ChatMessageGroupKind.System, metrics.Groups[0].Kind); + Assert.Equal(ChatMessageGroupKind.UserTurn, metrics.Groups[1].Kind); + Assert.Equal(ChatMessageGroupKind.AssistantPlain, metrics.Groups[2].Kind); + } + + [Fact] + public void EmptyList_GroupIndexIsEmpty() + { + DefaultChatHistoryMetricsCalculator calculator = new(); + CompactionMetric metrics = calculator.Calculate([]); + + Assert.Empty(metrics.Groups); + } + + [Fact] + public void GroupIndex_SystemMessage_IdentifiedCorrectly() + { + DefaultChatHistoryMetricsCalculator calculator = new(); + List messages = + [ + new(ChatRole.System, "You are a helpful assistant"), + ]; + + IReadOnlyList groups = calculator.Calculate(messages).Groups; + + Assert.Single(groups); + Assert.Equal(ChatMessageGroupKind.System, groups[0].Kind); + Assert.Equal(0, groups[0].StartIndex); + Assert.Equal(1, groups[0].Count); + } + + [Fact] + public void GroupIndex_AssistantWithToolCalls_GroupedWithResults() + { + DefaultChatHistoryMetricsCalculator calculator = new(); + ChatMessage assistantMsg = new(ChatRole.Assistant, [ + new FunctionCallContent("call1", "get_weather", new Dictionary { ["city"] = "NYC" }), + ]); + ChatMessage toolResult = new(ChatRole.Tool, [ + new FunctionResultContent("call1", "Sunny, 72°F"), + ]); + List messages = + [ + new(ChatRole.User, "What's the weather?"), + assistantMsg, + toolResult, + ]; + + IReadOnlyList groups = calculator.Calculate(messages).Groups; + + Assert.Equal(2, groups.Count); + Assert.Equal(ChatMessageGroupKind.UserTurn, groups[0].Kind); + Assert.Equal(ChatMessageGroupKind.AssistantToolGroup, groups[1].Kind); + Assert.Equal(1, groups[1].StartIndex); + Assert.Equal(2, groups[1].Count); // assistant + tool result + } + + [Fact] + public void GroupIndex_MultipleToolResults_GroupedTogether() + { + DefaultChatHistoryMetricsCalculator calculator = new(); + ChatMessage assistantMsg = new(ChatRole.Assistant, [ + new FunctionCallContent("c1", "func1"), + new FunctionCallContent("c2", "func2"), + ]); + ChatMessage tool1 = new(ChatRole.Tool, [new FunctionResultContent("c1", "result1")]); + ChatMessage tool2 = new(ChatRole.Tool, [new FunctionResultContent("c2", "result2")]); + List messages = [assistantMsg, tool1, tool2]; + + IReadOnlyList groups = calculator.Calculate(messages).Groups; + + Assert.Single(groups); + Assert.Equal(ChatMessageGroupKind.AssistantToolGroup, groups[0].Kind); + Assert.Equal(3, groups[0].Count); + } + + [Fact] + public void GroupIndex_ComplexConversation_CorrectGrouping() + { + DefaultChatHistoryMetricsCalculator calculator = new(); + List messages = + [ + new(ChatRole.System, "You are a helper"), + new(ChatRole.User, "Hi"), + new(ChatRole.Assistant, "Hello!"), + new(ChatRole.User, "Get weather"), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "get_weather")]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c1", "Sunny")]), + new(ChatRole.Assistant, "It's sunny!"), + ]; + + IReadOnlyList groups = calculator.Calculate(messages).Groups; + + Assert.Equal(6, groups.Count); + Assert.Equal(ChatMessageGroupKind.System, groups[0].Kind); + Assert.Equal(ChatMessageGroupKind.UserTurn, groups[1].Kind); + Assert.Equal(ChatMessageGroupKind.AssistantPlain, groups[2].Kind); + Assert.Equal(ChatMessageGroupKind.UserTurn, groups[3].Kind); + Assert.Equal(ChatMessageGroupKind.AssistantToolGroup, groups[4].Kind); + Assert.Equal(2, groups[4].Count); // assistant + tool + Assert.Equal(ChatMessageGroupKind.AssistantPlain, groups[5].Kind); + } + + [Fact] + public void GroupIndex_OrphanToolResult_IdentifiedCorrectly() + { + DefaultChatHistoryMetricsCalculator calculator = new(); + List messages = + [ + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c1", "orphan result")]), + ]; + + IReadOnlyList groups = calculator.Calculate(messages).Groups; + + Assert.Single(groups); + Assert.Equal(ChatMessageGroupKind.ToolResult, groups[0].Kind); + } + + [Fact] + public void GroupIndex_UnknownRole_IdentifiedAsOther() + { + DefaultChatHistoryMetricsCalculator calculator = new(); + List messages = + [ + new(new ChatRole("custom"), "custom message"), + ]; + + IReadOnlyList groups = calculator.Calculate(messages).Groups; + + Assert.Single(groups); + Assert.Equal(ChatMessageGroupKind.Other, groups[0].Kind); + } + + [Fact] + public void GroupIndex_AssistantWithNullContents_ClassifiedAsPlain() + { + DefaultChatHistoryMetricsCalculator calculator = new(); + ChatMessage msg = new(ChatRole.Assistant, "reply"); + msg.Contents = null!; + List messages = [msg]; + + IReadOnlyList groups = calculator.Calculate(messages).Groups; + + Assert.Single(groups); + Assert.Equal(ChatMessageGroupKind.AssistantPlain, groups[0].Kind); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/Internal/AgentRunContextHarness.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/Internal/AgentRunContextHarness.cs new file mode 100644 index 0000000000..6d9ae2eac1 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/Internal/AgentRunContextHarness.cs @@ -0,0 +1,52 @@ +// 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.Extensions.AI; + +namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction.Internal; + +/// +/// Provides a way to set in unit tests +/// so that the underlying AsyncLocal is populated for code that reads it. +/// +internal static class AgentRunContextHarness +{ + private static readonly ContextAgentShim s_instance = new(); + + /// + /// Sets and invokes the provided action. + /// + public static void ExecuteWithRunContext(AgentRunContext context, Action action) + { + Assert.NotNull(context); + Assert.NotNull(action); + //AgentRunContext context = new(agent, session, messages ?? [], options); // %%% TODO + s_instance.Set(context); + action.Invoke(); + } + + // Derived class that exposes the protected setter. + private sealed class ContextAgentShim : AIAgent + { + public void Set(AgentRunContext? value) => CurrentRunContext = value; + + protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + protected override ValueTask SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + protected override ValueTask DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + protected override Task RunCoreAsync(IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + protected override IAsyncEnumerable RunCoreStreamingAsync(IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/Internal/NeverCompactStrategy.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/Internal/NeverCompactStrategy.cs new file mode 100644 index 0000000000..9a4429e08a --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/Internal/NeverCompactStrategy.cs @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Compaction; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction.Internal; + +internal sealed class NeverCompactStrategy : ChatHistoryCompactionStrategy +{ + public NeverCompactStrategy() + : base(new NoOpReducer()) + { + } + + public override string Name => "NeverCompact"; + public override bool ShouldCompact(CompactionMetric metrics) => false; + + private sealed class NoOpReducer : IChatReducer + { + public Task> ReduceAsync(IEnumerable messages, CancellationToken cancellationToken = default) + => Task.FromResult(messages); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/Internal/NonReadOnlyList.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/Internal/NonReadOnlyList.cs new file mode 100644 index 0000000000..101388c921 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/Internal/NonReadOnlyList.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections; +using System.Collections.Generic; +using System.Linq; + +namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction.Internal; + +/// +/// An IList<T> that does NOT implement IReadOnlyList<T>, +/// used to test the defensive as IReadOnlyList<T> ?? fallback patterns. +/// +internal sealed class NonReadOnlyList : IList +{ + private readonly List _inner; + + public NonReadOnlyList(IEnumerable items) + { + this._inner = items.ToList(); + } + + public T this[int index] + { + get => this._inner[index]; + set => this._inner[index] = value; + } + + public int Count => this._inner.Count; + public bool IsReadOnly => false; + public void Add(T item) => this._inner.Add(item); + public void Clear() => this._inner.Clear(); + public bool Contains(T item) => this._inner.Contains(item); + public void CopyTo(T[] array, int arrayIndex) => this._inner.CopyTo(array, arrayIndex); + public IEnumerator GetEnumerator() => this._inner.GetEnumerator(); + public int IndexOf(T item) => this._inner.IndexOf(item); + public void Insert(int index, T item) => this._inner.Insert(index, item); + public bool Remove(T item) => this._inner.Remove(item); + public void RemoveAt(int index) => this._inner.RemoveAt(index); + IEnumerator IEnumerable.GetEnumerator() => this.GetEnumerator(); +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/Internal/RemoveFirstMessageStrategy.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/Internal/RemoveFirstMessageStrategy.cs new file mode 100644 index 0000000000..f2ca36bc10 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/Internal/RemoveFirstMessageStrategy.cs @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft. All rights reserved. + +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.Abstractions.UnitTests.Compaction.Internal; + +internal sealed class RemoveFirstMessageStrategy : ChatHistoryCompactionStrategy +{ + public RemoveFirstMessageStrategy() + : base(new RemoveFirstReducer()) + { + } + + public override string Name => "RemoveFirst"; + + public override bool ShouldCompact(CompactionMetric metrics) => metrics.MessageCount > 0; + + private sealed class RemoveFirstReducer : IChatReducer + { + public Task> ReduceAsync(IEnumerable messages, CancellationToken cancellationToken = default) + { + List list = messages.ToList(); + if (list.Count > 1) + { + list.RemoveAt(0); + } + + return Task.FromResult>(list); + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/MessageGroupTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/MessageGroupTests.cs new file mode 100644 index 0000000000..5449b4bbf9 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/MessageGroupTests.cs @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI.Compaction; + +namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction; + +public class MessageGroupTests +{ + [Fact] + public void Equality_Works() + { + ChatMessageGroup a = new(0, 2, ChatMessageGroupKind.AssistantToolGroup); + ChatMessageGroup b = new(0, 2, ChatMessageGroupKind.AssistantToolGroup); + ChatMessageGroup c = new(1, 2, ChatMessageGroupKind.AssistantToolGroup); + + Assert.Equal(a, b); + Assert.True(a == b); + Assert.NotEqual(a, c); + Assert.True(a != c); + } + + [Fact] + public void Equals_Object_NullReturnsFalse() + { + ChatMessageGroup group = new(0, 1, ChatMessageGroupKind.System); + + Assert.False(group.Equals(null)); + } + + [Fact] + public void Equals_Object_BoxedMessageGroupReturnsTrue() + { + ChatMessageGroup group = new(0, 2, ChatMessageGroupKind.AssistantToolGroup); + object boxed = new ChatMessageGroup(0, 2, ChatMessageGroupKind.AssistantToolGroup); + + Assert.True(group.Equals(boxed)); + } + + [Fact] + public void Equals_Object_WrongTypeReturnsFalse() + { + ChatMessageGroup group = new(0, 1, ChatMessageGroupKind.System); + + Assert.False(group.Equals("not a MessageGroup")); + } + + [Fact] + public void GetHashCode_ConsistentForEqualInstances() + { + ChatMessageGroup a = new(0, 2, ChatMessageGroupKind.AssistantToolGroup); + ChatMessageGroup b = new(0, 2, ChatMessageGroupKind.AssistantToolGroup); + + Assert.Equal(a.GetHashCode(), b.GetHashCode()); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/SlidingWindowCompactionStrategyTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/SlidingWindowCompactionStrategyTests.cs new file mode 100644 index 0000000000..dee020130b --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/SlidingWindowCompactionStrategyTests.cs @@ -0,0 +1,158 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Compaction; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction; + +public class SlidingWindowCompactionStrategyTests +{ + [Fact] + public void ShouldCompact_UnderLimit_ReturnsFalse() + { + SlidingWindowCompactionStrategy strategy = new(maxTurns: 10); + CompactionMetric metrics = new() { UserTurnCount = 3 }; + + Assert.False(strategy.ShouldCompact(metrics)); + } + + [Fact] + public void ShouldCompact_OverLimit_ReturnsTrue() + { + SlidingWindowCompactionStrategy strategy = new(maxTurns: 2); + CompactionMetric metrics = new() { UserTurnCount = 5 }; + + Assert.True(strategy.ShouldCompact(metrics)); + } + + [Fact] + public async Task UnderLimit_NoChangeAsync() + { + SlidingWindowCompactionStrategy strategy = new(maxTurns: 10); + List messages = + [ + new(ChatRole.User, "Hello"), + new(ChatRole.Assistant, "Hi"), + ]; + DefaultChatHistoryMetricsCalculator calculator = new(); + + CompactionResult result = await strategy.CompactAsync(messages, calculator); + + Assert.False(result.Applied); + Assert.Equal(2, messages.Count); + } + + [Fact] + public async Task KeepsLastNTurnsAsync() + { + SlidingWindowCompactionStrategy strategy = new(maxTurns: 2); + List messages = + [ + new(ChatRole.User, "Turn 1"), + new(ChatRole.Assistant, "Reply 1"), + new(ChatRole.User, "Turn 2"), + new(ChatRole.Assistant, "Reply 2"), + new(ChatRole.User, "Turn 3"), + new(ChatRole.Assistant, "Reply 3"), + ]; + DefaultChatHistoryMetricsCalculator calculator = new(); + + CompactionResult result = await strategy.CompactAsync(messages, calculator); + + Assert.True(result.Applied); + Assert.Equal(4, messages.Count); + Assert.Equal("Turn 2", messages[0].Text); + Assert.Equal("Reply 2", messages[1].Text); + Assert.Equal("Turn 3", messages[2].Text); + Assert.Equal("Reply 3", messages[3].Text); + } + + [Fact] + public async Task PreservesSystemMessagesAsync() + { + SlidingWindowCompactionStrategy strategy = new(maxTurns: 1); + List messages = + [ + new(ChatRole.System, "You are a helper"), + new(ChatRole.User, "Turn 1"), + new(ChatRole.Assistant, "Reply 1"), + new(ChatRole.User, "Turn 2"), + new(ChatRole.Assistant, "Reply 2"), + ]; + DefaultChatHistoryMetricsCalculator calculator = new(); + + CompactionResult result = await strategy.CompactAsync(messages, calculator); + + Assert.True(result.Applied); + Assert.Equal(3, messages.Count); + Assert.Equal(ChatRole.System, messages[0].Role); + Assert.Equal("You are a helper", messages[0].Text); + Assert.Equal("Turn 2", messages[1].Text); + Assert.Equal("Reply 2", messages[2].Text); + } + + [Fact] + public async Task PreservesToolGroupsWithinKeptTurnsAsync() + { + SlidingWindowCompactionStrategy strategy = new(maxTurns: 1); + List messages = + [ + new(ChatRole.User, "Turn 1"), + new(ChatRole.Assistant, "Reply 1"), + new(ChatRole.User, "Get weather"), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "get_weather")]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c1", "Sunny")]), + new(ChatRole.Assistant, "It's sunny!"), + ]; + DefaultChatHistoryMetricsCalculator calculator = new(); + + CompactionResult result = await strategy.CompactAsync(messages, calculator); + + Assert.True(result.Applied); + // Turn 1 dropped, Turn 2 kept (user + assistant-tool-group + plain assistant) + Assert.Equal(4, messages.Count); + Assert.Equal("Get weather", messages[0].Text); + } + + [Fact] + public async Task SingleTurn_AtLimit_NoChangeAsync() + { + SlidingWindowCompactionStrategy strategy = new(maxTurns: 1); + List messages = + [ + new(ChatRole.User, "Hello"), + new(ChatRole.Assistant, "Hi"), + ]; + DefaultChatHistoryMetricsCalculator calculator = new(); + + CompactionResult result = await strategy.CompactAsync(messages, calculator); + + Assert.False(result.Applied); + Assert.Equal(2, messages.Count); + } + + [Fact] + public async Task DropsResponseGroupsFromOldTurnsAsync() + { + SlidingWindowCompactionStrategy strategy = new(maxTurns: 1); + List messages = + [ + new(ChatRole.User, "Turn 1"), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "search")]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c1", "result")]), + new(ChatRole.Assistant, "Here's what I found"), + new(ChatRole.User, "Turn 2"), + new(ChatRole.Assistant, "Reply 2"), + ]; + DefaultChatHistoryMetricsCalculator calculator = new(); + + CompactionResult result = await strategy.CompactAsync(messages, calculator); + + Assert.True(result.Applied); + Assert.Equal(2, messages.Count); + Assert.Equal("Turn 2", messages[0].Text); + Assert.Equal("Reply 2", messages[1].Text); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/SummarizationCompactionStrategyTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/SummarizationCompactionStrategyTests.cs new file mode 100644 index 0000000000..02782ad754 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/SummarizationCompactionStrategyTests.cs @@ -0,0 +1,190 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Compaction; +using Microsoft.Extensions.AI; +using Moq; + +namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction; + +public class SummarizationCompactionStrategyTests +{ + [Fact] + public void ShouldCompact_UnderLimit_ReturnsFalse() + { + Mock chatClientMock = new(); + SummarizationCompactionStrategy strategy = new(chatClientMock.Object, maxTokens: 100000); + CompactionMetric metrics = new() { TokenCount = 500 }; + + Assert.False(strategy.ShouldCompact(metrics)); + } + + [Fact] + public void ShouldCompact_OverLimit_ReturnsTrue() + { + Mock chatClientMock = new(); + SummarizationCompactionStrategy strategy = new(chatClientMock.Object, maxTokens: 100); + CompactionMetric metrics = new() { TokenCount = 500 }; + + Assert.True(strategy.ShouldCompact(metrics)); + } + + [Fact] + public async Task UnderLimit_NoChangeAsync() + { + Mock chatClientMock = new(); + SummarizationCompactionStrategy strategy = new(chatClientMock.Object, maxTokens: 100000); + List messages = + [ + new(ChatRole.User, "Hello"), + new(ChatRole.Assistant, "Hi"), + ]; + DefaultChatHistoryMetricsCalculator calculator = new(); + + CompactionResult result = await strategy.CompactAsync(messages, calculator); + + Assert.False(result.Applied); + Assert.Equal(2, messages.Count); + chatClientMock.Verify( + c => c.GetResponseAsync(It.IsAny>(), It.IsAny(), It.IsAny()), + Times.Never); + } + + [Fact] + public async Task SummarizesOldGroupsAsync() + { + Mock chatClientMock = new(); + chatClientMock + .Setup(c => c.GetResponseAsync(It.IsAny>(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "User asked about weather. It was sunny."))); + + // preserveRecentGroups=2 means keep last 2 non-system groups + SummarizationCompactionStrategy strategy = new(chatClientMock.Object, maxTokens: 1, preserveRecentGroups: 2); + List messages = + [ + new(ChatRole.User, "What's the weather?"), + new(ChatRole.Assistant, "The weather is sunny and 72°F."), + new(ChatRole.User, "How about tomorrow?"), + new(ChatRole.Assistant, "Tomorrow will be cloudy."), + new(ChatRole.User, "Thanks!"), + new(ChatRole.Assistant, "You're welcome!"), + ]; + DefaultChatHistoryMetricsCalculator calculator = new(); + + CompactionResult result = await strategy.CompactAsync(messages, calculator); + + Assert.True(result.Applied); + // 6 groups (3 user + 3 assistant), protect last 2 → summarize first 4 groups + // Result: summary + 2 protected groups = 3 messages + Assert.Equal(3, messages.Count); + Assert.Contains("[Summary]", messages[0].Text); + Assert.Contains("sunny", messages[0].Text); + Assert.Equal("Thanks!", messages[1].Text); + Assert.Equal("You're welcome!", messages[2].Text); + } + + [Fact] + public async Task PreservesSystemMessagesAsync() + { + Mock chatClientMock = new(); + chatClientMock + .Setup(c => c.GetResponseAsync(It.IsAny>(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "Summary of earlier discussion."))); + + SummarizationCompactionStrategy strategy = new(chatClientMock.Object, maxTokens: 1, preserveRecentGroups: 1); + List messages = + [ + new(ChatRole.System, "You are a helper"), + new(ChatRole.User, "Turn 1"), + new(ChatRole.Assistant, "Reply 1"), + new(ChatRole.User, "Turn 2"), + new(ChatRole.Assistant, "Reply 2"), + ]; + DefaultChatHistoryMetricsCalculator calculator = new(); + + CompactionResult result = await strategy.CompactAsync(messages, calculator); + + Assert.True(result.Applied); + Assert.Equal(ChatRole.System, messages[0].Role); + Assert.Equal("You are a helper", messages[0].Text); + Assert.Contains("[Summary]", messages[1].Text); + } + + [Fact] + public async Task AllGroupsProtected_NoChangeAsync() + { + Mock chatClientMock = new(); + SummarizationCompactionStrategy strategy = new(chatClientMock.Object, maxTokens: 1, preserveRecentGroups: 10); + List messages = + [ + new(ChatRole.User, "Hello"), + new(ChatRole.Assistant, "Hi"), + ]; + DefaultChatHistoryMetricsCalculator calculator = new(); + + CompactionResult result = await strategy.CompactAsync(messages, calculator); + + // All groups protected → nothing to summarize → no change + Assert.False(result.Applied); + Assert.Equal(2, messages.Count); + chatClientMock.Verify( + c => c.GetResponseAsync(It.IsAny>(), It.IsAny(), It.IsAny()), + Times.Never); + } + + [Fact] + public async Task CustomPrompt_UsedInRequestAsync() + { + const string CustomPrompt = "Summarize briefly."; + List? capturedMessages = null; + + Mock chatClientMock = new(); + chatClientMock + .Setup(c => c.GetResponseAsync(It.IsAny>(), It.IsAny(), It.IsAny())) + .Callback, ChatOptions, CancellationToken>((msgs, _, _) => capturedMessages = [.. msgs]) + .ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "Brief summary."))); + + SummarizationCompactionStrategy strategy = new(chatClientMock.Object, maxTokens: 1, preserveRecentGroups: 1, summarizationPrompt: CustomPrompt); + List messages = + [ + new(ChatRole.User, "First"), + new(ChatRole.Assistant, "Reply"), + new(ChatRole.User, "Second"), + new(ChatRole.Assistant, "Reply 2"), + ]; + DefaultChatHistoryMetricsCalculator calculator = new(); + + await strategy.CompactAsync(messages, calculator); + + Assert.NotNull(capturedMessages); + // First message in request should be the custom system prompt + Assert.Equal(ChatRole.System, capturedMessages![0].Role); + Assert.Equal(CustomPrompt, capturedMessages[0].Text); + } + + [Fact] + public async Task NullResponseText_UsesFallbackAsync() + { + Mock chatClientMock = new(); + chatClientMock + .Setup(c => c.GetResponseAsync(It.IsAny>(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, (string?)null))); + + SummarizationCompactionStrategy strategy = new(chatClientMock.Object, maxTokens: 1, preserveRecentGroups: 1); + List messages = + [ + new(ChatRole.User, "First"), + new(ChatRole.Assistant, "Reply"), + new(ChatRole.User, "Second"), + new(ChatRole.Assistant, "Reply 2"), + ]; + DefaultChatHistoryMetricsCalculator calculator = new(); + + CompactionResult result = await strategy.CompactAsync(messages, calculator); + + Assert.True(result.Applied); + Assert.Contains("[Summary unavailable]", messages[0].Text); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/ToolResultCompactionStrategyTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/ToolResultCompactionStrategyTests.cs new file mode 100644 index 0000000000..8a6065a940 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/ToolResultCompactionStrategyTests.cs @@ -0,0 +1,168 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Compaction; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction; + +public class ToolResultCompactionStrategyTests +{ + [Fact] + public void ShouldCompact_UnderLimit_ReturnsFalse() + { + ToolResultCompactionStrategy strategy = new(maxTokens: 100000); + CompactionMetric metrics = new() { TokenCount = 500, ToolCallCount = 3 }; + + Assert.False(strategy.ShouldCompact(metrics)); + } + + [Fact] + public void ShouldCompact_OverLimitNoToolCalls_ReturnsFalse() + { + ToolResultCompactionStrategy strategy = new(maxTokens: 100); + CompactionMetric metrics = new() { TokenCount = 500, ToolCallCount = 0 }; + + Assert.False(strategy.ShouldCompact(metrics)); + } + + [Fact] + public void ShouldCompact_OverLimitWithToolCalls_ReturnsTrue() + { + ToolResultCompactionStrategy strategy = new(maxTokens: 100); + CompactionMetric metrics = new() { TokenCount = 500, ToolCallCount = 2 }; + + Assert.True(strategy.ShouldCompact(metrics)); + } + + [Fact] + public async Task UnderLimit_NoChangeAsync() + { + ToolResultCompactionStrategy strategy = new(maxTokens: 100000); + List messages = + [ + new(ChatRole.User, "Hello"), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "get_weather")]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c1", "Sunny")]), + ]; + DefaultChatHistoryMetricsCalculator calculator = new(); + + CompactionResult result = await strategy.CompactAsync(messages, calculator); + + Assert.False(result.Applied); + Assert.Equal(3, messages.Count); + } + + [Fact] + public async Task CollapsesOldToolGroupAsync() + { + ToolResultCompactionStrategy strategy = new(maxTokens: 1, preserveRecentGroups: 1); + List messages = + [ + new(ChatRole.User, "Check weather"), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "get_weather")]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c1", "Sunny, 72°F")]), + new(ChatRole.User, "Thanks"), + new(ChatRole.Assistant, "You're welcome!"), + ]; + DefaultChatHistoryMetricsCalculator calculator = new(); + + CompactionResult result = await strategy.CompactAsync(messages, calculator); + + Assert.True(result.Applied); + // The old tool group (assistant+tool = 2 messages) should be collapsed to 1 + Assert.Equal(4, messages.Count); // user + [collapsed] + user + assistant + Assert.Contains("[Tool calls: get_weather]", messages[1].Text); + Assert.Equal(ChatRole.Assistant, messages[1].Role); + } + + [Fact] + public async Task ProtectsRecentGroupsAsync() + { + // With preserveRecentGroups=4, all groups are protected (5 groups, protect 4 non-system) + ToolResultCompactionStrategy strategy = new(maxTokens: 1, preserveRecentGroups: 10); + List messages = + [ + new(ChatRole.User, "Check weather"), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "get_weather")]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c1", "Sunny")]), + new(ChatRole.User, "Thanks"), + new(ChatRole.Assistant, "You're welcome!"), + ]; + DefaultChatHistoryMetricsCalculator calculator = new(); + + CompactionResult result = await strategy.CompactAsync(messages, calculator); + + // All groups protected, so no collapse + Assert.False(result.Applied); + Assert.Equal(5, messages.Count); + } + + [Fact] + public async Task PreservesSystemMessagesAsync() + { + ToolResultCompactionStrategy strategy = new(maxTokens: 1, preserveRecentGroups: 1); + List messages = + [ + new(ChatRole.System, "You are a helper"), + new(ChatRole.User, "Check weather"), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "get_weather")]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c1", "Sunny")]), + new(ChatRole.User, "Thanks"), + new(ChatRole.Assistant, "You're welcome!"), + ]; + DefaultChatHistoryMetricsCalculator calculator = new(); + + CompactionResult result = await strategy.CompactAsync(messages, calculator); + + Assert.True(result.Applied); + Assert.Equal(ChatRole.System, messages[0].Role); + Assert.Equal("You are a helper", messages[0].Text); + } + + [Fact] + public async Task MultipleToolCalls_ListedInSummaryAsync() + { + ToolResultCompactionStrategy strategy = new(maxTokens: 1, preserveRecentGroups: 1); + List messages = + [ + new(ChatRole.User, "Do research"), + new ChatMessage(ChatRole.Assistant, [ + new FunctionCallContent("c1", "search"), + new FunctionCallContent("c2", "fetch_page"), + ]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c1", "results...")]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c2", "page content...")]), + new(ChatRole.User, "Summarize"), + new(ChatRole.Assistant, "Here's the summary."), + ]; + DefaultChatHistoryMetricsCalculator calculator = new(); + + CompactionResult result = await strategy.CompactAsync(messages, calculator); + + Assert.True(result.Applied); + // Old tool group (1 assistant + 2 tools = 3 messages) collapsed to 1 + Assert.Equal(4, messages.Count); + Assert.Contains("search", messages[1].Text); + Assert.Contains("fetch_page", messages[1].Text); + } + + [Fact] + public async Task NoToolGroups_NoChangeAsync() + { + ToolResultCompactionStrategy strategy = new(maxTokens: 1); + List messages = + [ + new(ChatRole.User, "Hello"), + new(ChatRole.Assistant, "Hi there"), + ]; + DefaultChatHistoryMetricsCalculator calculator = new(); + + // ToolCallCount is 0, so ShouldCompact returns false + CompactionResult result = await strategy.CompactAsync(messages, calculator); + + Assert.False(result.Applied); + Assert.Equal(2, messages.Count); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/TruncationCompactionStrategyTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/TruncationCompactionStrategyTests.cs new file mode 100644 index 0000000000..47929d23e5 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/TruncationCompactionStrategyTests.cs @@ -0,0 +1,130 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Compaction; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction; + +public class TruncationCompactionStrategyTests +{ + [Fact] + public async Task UnderLimit_NoChangeAsync() + { + TruncationCompactionStrategy strategy = new(maxTokens: 100000); + List messages = + [ + new(ChatRole.User, "Hello"), + ]; + DefaultChatHistoryMetricsCalculator calculator = new(); + + CompactionResult result = await strategy.CompactAsync(messages, calculator); + + Assert.False(result.Applied); + Assert.Single(messages); + } + + [Fact] + public async Task OverLimit_RemovesOldestGroupsAsync() + { + // Use a very low max to trigger compaction + TruncationCompactionStrategy strategy = new(maxTokens: 1); + List messages = + [ + new(ChatRole.User, "First message"), + new(ChatRole.Assistant, "First reply"), + new(ChatRole.User, "Second message"), + new(ChatRole.Assistant, "Second reply"), + ]; + DefaultChatHistoryMetricsCalculator calculator = new(); + + CompactionResult result = await strategy.CompactAsync(messages, calculator); + + Assert.True(result.Applied); + // Should have removed old groups, keeping at least the last one + Assert.True(messages.Count < 4); + Assert.True(messages.Count > 0); + } + + [Fact] + public void ShouldCompact_ReturnsFalseWhenUnderLimit() + { + TruncationCompactionStrategy strategy = new(maxTokens: 10000); + CompactionMetric metrics = new() { TokenCount = 500 }; + + Assert.False(strategy.ShouldCompact(metrics)); + } + + [Fact] + public void ShouldCompact_ReturnsTrueWhenOverLimit() + { + TruncationCompactionStrategy strategy = new(maxTokens: 100); + CompactionMetric metrics = new() { TokenCount = 500 }; + + Assert.True(strategy.ShouldCompact(metrics)); + } + + [Fact] + public async Task SystemOnlyMessages_NoChangeAsync() + { + // Only system messages → removableGroups.Count == 0 → no change + TruncationCompactionStrategy strategy = new(maxTokens: 1); + List messages = + [ + new(ChatRole.System, "You are a helper"), + ]; + DefaultChatHistoryMetricsCalculator calculator = new(); + + CompactionResult result = await strategy.CompactAsync(messages, calculator); + + // ShouldCompact triggers but reducer finds nothing removable + Assert.Single(messages); + Assert.Equal(result.After.MessageCount, result.Before.MessageCount); + Assert.Equal(result.After.TokenCount, result.Before.TokenCount); + } + + [Fact] + public async Task SingleNonSystemGroup_NoChangeAsync() + { + // Only one non-system group → maxRemovable <= 0 → no change + TruncationCompactionStrategy strategy = new(maxTokens: 1); + List messages = + [ + new(ChatRole.System, "System prompt"), + new(ChatRole.User, "Only user message"), + ]; + DefaultChatHistoryMetricsCalculator calculator = new(); + + CompactionResult result = await strategy.CompactAsync(messages, calculator); + + Assert.Equal(2, messages.Count); + Assert.Equal(result.After.MessageCount, result.Before.MessageCount); + Assert.Equal(result.After.TokenCount, result.Before.TokenCount); + } + + [Fact] + public async Task PreserveRecentGroups_KeepsMultipleGroupsAsync() + { + // preserveRecentGroups=2 means keep at least the last 2 non-system groups + TruncationCompactionStrategy strategy = new(maxTokens: 1, preserveRecentGroups: 2); + List messages = + [ + new(ChatRole.User, "Turn 1"), + new(ChatRole.Assistant, "Reply 1"), + new(ChatRole.User, "Turn 2"), + new(ChatRole.Assistant, "Reply 2"), + new(ChatRole.User, "Turn 3"), + new(ChatRole.Assistant, "Reply 3"), + ]; + DefaultChatHistoryMetricsCalculator calculator = new(); + + CompactionResult result = await strategy.CompactAsync(messages, calculator); + + Assert.True(result.Applied); + // 6 groups (3 user + 3 assistant), protect last 2 → 4 removable + // Should keep at least: Turn 3 user + Reply 3 assistant (last 2 groups) + Assert.True(messages.Count >= 2); + Assert.Equal("Reply 3", messages[^1].Text); + } +} From 42d424587d02eade62e75a963d7dbd7325680b71 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Mon, 2 Mar 2026 13:46:28 -0800 Subject: [PATCH 2/6] Sample --- dotnet/agent-framework-dotnet.slnx | 1 + .../Agent_Step18_CompactionPipeline.csproj | 21 ++++ .../Program.cs | 97 +++++++++++++++++++ .../ChatHistoryCompactionPipeline.cs | 2 +- 4 files changed, 120 insertions(+), 1 deletion(-) create mode 100644 dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/Agent_Step18_CompactionPipeline.csproj create mode 100644 dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/Program.cs diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 1ab73c2b8b..7c1d0d48eb 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -56,6 +56,7 @@ + diff --git a/dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/Agent_Step18_CompactionPipeline.csproj b/dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/Agent_Step18_CompactionPipeline.csproj new file mode 100644 index 0000000000..0f9de7c359 --- /dev/null +++ b/dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/Agent_Step18_CompactionPipeline.csproj @@ -0,0 +1,21 @@ + + + + Exe + net10.0 + + enable + enable + + + + + + + + + + + + + diff --git a/dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/Program.cs b/dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/Program.cs new file mode 100644 index 0000000000..7849cb4656 --- /dev/null +++ b/dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/Program.cs @@ -0,0 +1,97 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample demonstrates how to use a ChatHistoryCompactionPipeline as the ChatReducer for an agent's +// in-memory chat history. The pipeline chains multiple compaction strategies from gentle to aggressive: +// 1. ToolResultCompactionStrategy - Collapses old tool-call groups into concise summaries +// 2. SummarizationCompactionStrategy - LLM-compresses older conversation spans +// 3. SlidingWindowCompactionStrategy - Keeps only the most recent N user turns +// 4. TruncationCompactionStrategy - Emergency token-budget backstop + +using System.ComponentModel; +using Azure.AI.OpenAI; +using Azure.Identity; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Compaction; +using Microsoft.Extensions.AI; + +var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); +var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; + +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. +AzureOpenAIClient openAIClient = new(new Uri(endpoint), new DefaultAzureCredential()); + +// Create a chat client for the agent and a separate one for the summarization strategy. +// Using the same model for simplicity; in production, use a smaller/cheaper model for summarization. +IChatClient agentChatClient = openAIClient.GetChatClient(deploymentName).AsIChatClient(); +IChatClient summarizerChatClient = openAIClient.GetChatClient(deploymentName).AsIChatClient(); + +// Define a tool the agent can use, so we can see tool-result compaction in action. +[Description("Look up the current price of a product by name.")] +static string LookupPrice([Description("The product name to look up.")] string productName) => + productName.ToUpperInvariant() switch + { + "LAPTOP" => "The laptop costs $999.99.", + "KEYBOARD" => "The keyboard costs $79.99.", + "MOUSE" => "The mouse costs $29.99.", + _ => $"Sorry, I don't have pricing for '{productName}'." + }; + +// Configure the compaction pipeline with one of each strategy, ordered least to most aggressive. +const int MaxTokens = 512; +const int MaxTurns = 4; + +ChatHistoryCompactionPipeline compactionPipeline = + new(// 1. Gentle: collapse old tool-call groups into short summaries like "[Tool calls: LookupPrice]" + new ToolResultCompactionStrategy(MaxTokens, preserveRecentGroups: 2), + + // 2. Moderate: use an LLM to summarize older conversation spans into a concise message + new SummarizationCompactionStrategy(summarizerChatClient, MaxTokens, preserveRecentGroups: 2), + + // 3. Aggressive: keep only the last N user turns and their responses + new SlidingWindowCompactionStrategy(MaxTurns), + + // 4. Emergency: drop oldest groups until under the token budget + new TruncationCompactionStrategy(MaxTokens, preserveRecentGroups: 1)); + +// Create the agent with an in-memory chat history provider whose reducer is the compaction pipeline. +AIAgent agent = agentChatClient.AsAIAgent(new ChatClientAgentOptions +{ + Name = "ShoppingAssistant", + ChatOptions = new() + { + Instructions = "You are a helpful shopping assistant. Help the user look up prices and compare products.", + Tools = [AIFunctionFactory.Create(LookupPrice)], + }, + ChatHistoryProvider = new InMemoryChatHistoryProvider(new() { ChatReducer = compactionPipeline }), +}); + +AgentSession session = await agent.CreateSessionAsync(); + +// Helper to print chat history size +void PrintChatHistory() +{ + if (session.TryGetInMemoryChatHistory(out var history)) + { + Console.WriteLine($" [Chat history: {history.Count} messages]\n"); + } +} + +// Run a multi-turn conversation with tool calls to exercise the pipeline. +string[] prompts = +[ + "What's the price of a laptop?", + "How about a keyboard?", + "And a mouse?", + "Which product is the cheapest?", + "Can you compare the laptop and the keyboard for me?", + "What was the first product I asked about?", +]; + +foreach (string prompt in prompts) +{ + Console.WriteLine($"User: {prompt}"); + Console.WriteLine($"Agent: {await agent.RunAsync(prompt, session)}"); + PrintChatHistory(); +} diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ChatHistoryCompactionPipeline.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ChatHistoryCompactionPipeline.cs index 451a171226..8b678602a1 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ChatHistoryCompactionPipeline.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ChatHistoryCompactionPipeline.cs @@ -37,7 +37,7 @@ public class ChatHistoryCompactionPipeline : IChatReducer /// By default, is used. /// public ChatHistoryCompactionPipeline( - IEnumerable strategies) + params IEnumerable strategies) : this(metricsCalculator: null, strategies) { } /// From d6c4dbea96d3ce185dcac9612e682432a08eabc6 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Mon, 2 Mar 2026 22:00:21 -0800 Subject: [PATCH 3/6] Checkpoint --- .../Program.cs | 54 +++++--- .../ChatHistoryCompactionPipeline.Factory.cs | 102 ++++++++++++++ .../ChatHistoryCompactionPipeline.cs | 44 +++--- .../ChatHistoryCompactionStrategy.cs | 57 ++++---- ...mpactionMetric.cs => ChatHistoryMetric.cs} | 2 +- .../ChatReducerCompactionStrategy.cs | 35 +++++ .../Compaction/CompactionPipelineResult.cs | 8 +- .../Compaction/CompactionResult.cs | 8 +- .../DefaultChatHistoryMetricsCalculator.cs | 2 +- .../IChatHistoryMetricsCalculator.cs | 8 +- .../SlidingWindowCompactionStrategy.cs | 23 ++-- .../SummarizationCompactionStrategy.cs | 4 +- .../ToolResultCompactionStrategy.cs | 11 +- .../TruncationCompactionStrategy.cs | 8 +- .../ChatHistoryCompactionPipelineTests.cs | 47 ++++--- .../ChatHistoryCompactionStrategyTests.cs | 116 ++++++---------- .../ChatReducerCompactionStrategyTests.cs | 114 ++++++++++++++++ .../Compaction/CompactionMetricTests.cs | 9 +- .../CompactionPipelineResultTests.cs | 21 ++- .../Compaction/CompactionResultTests.cs | 6 +- .../Compaction/CompactionStrategyTestBase.cs | 40 ++++++ ...efaultChatHistoryMetricsCalculatorTests.cs | 100 +++++++++++--- .../Internal/NeverCompactStrategy.cs | 3 +- .../Compaction/Internal/NonReadOnlyList.cs | 40 ------ .../Internal/RemoveFirstMessageStrategy.cs | 2 +- .../Compaction/MessageGroupTests.cs | 10 ++ .../SlidingWindowCompactionStrategyTests.cs | 83 ++++-------- .../SummarizationCompactionStrategyTests.cs | 128 +++++++----------- .../ToolResultCompactionStrategyTests.cs | 95 ++++--------- .../TruncationCompactionStrategyTests.cs | 83 ++++-------- 30 files changed, 739 insertions(+), 524 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ChatHistoryCompactionPipeline.Factory.cs rename dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/{CompactionMetric.cs => ChatHistoryMetric.cs} (97%) create mode 100644 dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ChatReducerCompactionStrategy.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/ChatReducerCompactionStrategyTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/CompactionStrategyTestBase.cs delete mode 100644 dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/Internal/NonReadOnlyList.cs diff --git a/dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/Program.cs b/dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/Program.cs index 7849cb4656..9a3e935e01 100644 --- a/dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/Program.cs +++ b/dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/Program.cs @@ -13,6 +13,7 @@ using Microsoft.Agents.AI; using Microsoft.Agents.AI.Compaction; using Microsoft.Extensions.AI; +using static Microsoft.Agents.AI.Compaction.ChatHistoryCompactionPipeline; var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; @@ -39,33 +40,45 @@ static string LookupPrice([Description("The product name to look up.")] string p }; // Configure the compaction pipeline with one of each strategy, ordered least to most aggressive. -const int MaxTokens = 512; -const int MaxTurns = 4; +//const int MaxTokens = 512; +//const int MaxTurns = 4; ChatHistoryCompactionPipeline compactionPipeline = - new(// 1. Gentle: collapse old tool-call groups into short summaries like "[Tool calls: LookupPrice]" - new ToolResultCompactionStrategy(MaxTokens, preserveRecentGroups: 2), + //new(// 1. Gentle: collapse old tool-call groups into short summaries like "[Tool calls: LookupPrice]" + // new ToolResultCompactionStrategy(MaxTokens, preserveRecentGroups: 2), - // 2. Moderate: use an LLM to summarize older conversation spans into a concise message - new SummarizationCompactionStrategy(summarizerChatClient, MaxTokens, preserveRecentGroups: 2), + // // 2. Moderate: use an LLM to summarize older conversation spans into a concise message + // new SummarizationCompactionStrategy(summarizerChatClient, MaxTokens, preserveRecentGroups: 2), - // 3. Aggressive: keep only the last N user turns and their responses - new SlidingWindowCompactionStrategy(MaxTurns), + // // 3. Aggressive: keep only the last N user turns and their responses + // new SlidingWindowCompactionStrategy(MaxTurns), - // 4. Emergency: drop oldest groups until under the token budget - new TruncationCompactionStrategy(MaxTokens, preserveRecentGroups: 1)); + // // 4. Emergency: drop oldest groups until under the token budget + // new TruncationCompactionStrategy(MaxTokens, preserveRecentGroups: 1)); + Create( + Approach.Balanced, + Size.Compact, + summarizerChatClient); // Create the agent with an in-memory chat history provider whose reducer is the compaction pipeline. -AIAgent agent = agentChatClient.AsAIAgent(new ChatClientAgentOptions -{ - Name = "ShoppingAssistant", - ChatOptions = new() - { - Instructions = "You are a helpful shopping assistant. Help the user look up prices and compare products.", - Tools = [AIFunctionFactory.Create(LookupPrice)], - }, - ChatHistoryProvider = new InMemoryChatHistoryProvider(new() { ChatReducer = compactionPipeline }), -}); +AIAgent agent = + agentChatClient.AsAIAgent( + new ChatClientAgentOptions + { + Name = "ShoppingAssistant", + ChatOptions = new() + { + Instructions = + """ + You are a helpful, but long winded, shopping assistant. + Help the user look up prices and compare products. + When responding, Be sure to be extra descriptive and use as + many words as possible without sounding ridiculous. + """, + Tools = [AIFunctionFactory.Create(LookupPrice)], + }, + ChatHistoryProvider = new InMemoryChatHistoryProvider(new() { ChatReducer = compactionPipeline }), + }); AgentSession session = await agent.CreateSessionAsync(); @@ -87,6 +100,7 @@ void PrintChatHistory() "Which product is the cheapest?", "Can you compare the laptop and the keyboard for me?", "What was the first product I asked about?", + "Thank you!", ]; foreach (string prompt in prompts) diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ChatHistoryCompactionPipeline.Factory.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ChatHistoryCompactionPipeline.Factory.cs new file mode 100644 index 0000000000..2e9cafd5a3 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ChatHistoryCompactionPipeline.Factory.cs @@ -0,0 +1,102 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Compaction; + +public partial class ChatHistoryCompactionPipeline +{ + /// + /// %%% COMMENT + /// + public enum Size + { + /// + /// %%% COMMENT + /// + Compact, + /// + /// %%% COMMENT + /// + Adequate, + /// + /// %%% COMMENT + /// + Accomodating, + } + + /// + /// %%% COMMENT + /// + public enum Approach + { + /// + /// %%% COMMENT + /// + Aggressive, + /// + /// %%% COMMENT + /// + Balanced, + /// + /// %%% COMMENT + /// + Gentle, + } + + /// + /// %%% COMMENT + /// + /// + /// + /// + /// + /// + public static ChatHistoryCompactionPipeline Create(Approach approach, Size size, IChatClient chatClient) => + approach switch + { + Approach.Aggressive => CreateAgressive(size, chatClient), + Approach.Balanced => CreateBalanced(size), + Approach.Gentle => CreateGentle(size), + _ => throw new NotImplementedException(), // %%% EXCEPTION + }; + + private static ChatHistoryCompactionPipeline CreateAgressive(Size size, IChatClient chatClient) => + new(// 1. Gentle: collapse old tool-call groups into short summaries like "[Tool calls: LookupPrice]" + new ToolResultCompactionStrategy(MaxTokens(size), preserveRecentGroups: 2), + // 2. Moderate: use an LLM to summarize older conversation spans into a concise message + new SummarizationCompactionStrategy(chatClient, MaxTokens(size), preserveRecentGroups: 2), + // 3. Aggressive: keep only the last N user turns and their responses + new SlidingWindowCompactionStrategy(MaxTurns(size)), + // 4. Emergency: drop oldest groups until under the token budget + new TruncationCompactionStrategy(MaxTokens(size), preserveRecentGroups: 1)); + + private static ChatHistoryCompactionPipeline CreateBalanced(Size size) => + new(// 1. Gentle: collapse old tool-call groups into short summaries like "[Tool calls: LookupPrice]" + new ToolResultCompactionStrategy(MaxTokens(size), preserveRecentGroups: 2), + // 2. Aggressive: keep only the last N user turns and their responses + new SlidingWindowCompactionStrategy(MaxTurns(size))); + + private static ChatHistoryCompactionPipeline CreateGentle(Size size) => + new(// 1. Gentle: collapse old tool-call groups into short summaries like "[Tool calls: LookupPrice]" + new ToolResultCompactionStrategy(MaxTokens(size), preserveRecentGroups: 2)); + + private static int MaxTokens(Size size) => + size switch + { + Size.Compact => 500, + Size.Adequate => 1000, + Size.Accomodating => 2000, + _ => throw new NotImplementedException(), // %%% EXCEPTION + }; + + private static int MaxTurns(Size size) => + size switch + { + Size.Compact => 10, + Size.Adequate => 50, + Size.Accomodating => 100, + _ => throw new NotImplementedException(), // %%% EXCEPTION + }; +} diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ChatHistoryCompactionPipeline.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ChatHistoryCompactionPipeline.cs index 8b678602a1..9dc4c4e507 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ChatHistoryCompactionPipeline.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ChatHistoryCompactionPipeline.cs @@ -1,7 +1,8 @@ // Copyright (c) Microsoft. All rights reserved. +using System; using System.Collections.Generic; -using System.Linq; +using System.Diagnostics; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.AI; @@ -24,7 +25,7 @@ namespace Microsoft.Agents.AI.Compaction; /// accepted (e.g., ). /// /// -public class ChatHistoryCompactionPipeline : IChatReducer +public partial class ChatHistoryCompactionPipeline : IChatReducer { private readonly ChatHistoryCompactionStrategy[] _strategies; private readonly IChatHistoryMetricsCalculator _metricsCalculator; @@ -52,7 +53,7 @@ public ChatHistoryCompactionPipeline( IChatHistoryMetricsCalculator? metricsCalculator, params IEnumerable strategies) { - this._strategies = Throw.IfNull(strategies).ToArray(); + this._strategies = [.. Throw.IfNull(strategies)]; this._metricsCalculator = metricsCalculator ?? DefaultChatHistoryMetricsCalculator.Instance; } @@ -66,9 +67,9 @@ public virtual async Task> ReduceAsync( IEnumerable messages, CancellationToken cancellationToken = default) { - List messageList = messages.ToList(); // %%% HAXX - await this.CompactAsync(messageList, cancellationToken).ConfigureAwait(false); - return messageList; + List messageBuffer = messages is List messageList ? messageList : [.. messages]; + await this.CompactAsync(messageBuffer, cancellationToken).ConfigureAwait(false); + return messageBuffer; } /// @@ -77,26 +78,37 @@ public virtual async Task> ReduceAsync( /// The mutable message list to compact. /// The to monitor for cancellation requests. /// A with aggregate and per-strategy metrics. - public async ValueTask CompactAsync( // %%% SCOPE - IList messages, + public async ValueTask CompactAsync( + List messages, CancellationToken cancellationToken = default) { Throw.IfNull(messages); - IReadOnlyList readOnlyMessages = messages as IReadOnlyList ?? [.. messages]; // %%% TYPE CONSISTENCY - CompactionMetric overallBefore = this._metricsCalculator.Calculate(readOnlyMessages); + ChatHistoryMetric overallBefore = this._metricsCalculator.Calculate(messages); - List results = new(this._strategies.Length); + Debug.WriteLine($"COMPACTION: BEGIN x{overallBefore.MessageCount}/#{overallBefore.UserTurnCount} ({overallBefore.TokenCount} tokens)"); + List compactionResults = new(this._strategies.Length); + + Stopwatch timer = new(); + TimeSpan startTime = TimeSpan.Zero; + ChatHistoryMetric overallAfter = overallBefore; + ChatHistoryMetric currentBefore = overallBefore; foreach (ChatHistoryCompactionStrategy strategy in this._strategies) { - CompactionResult result = await strategy.CompactAsync(messages, this._metricsCalculator, cancellationToken).ConfigureAwait(false); - results.Add(result); + // %%% VERBOSE - Debug.WriteLine($"COMPACTION: {strategy.Name} START"); + timer.Start(); + ChatHistoryCompactionStrategy.s_currentMetrics.Value = currentBefore; + CompactionResult strategyResult = await strategy.CompactAsync(messages, this._metricsCalculator, cancellationToken).ConfigureAwait(false); + timer.Stop(); + TimeSpan elapsedTime = timer.Elapsed - startTime; + // %%% VERBOSE - Debug.WriteLine($"COMPACTION: {strategy.Name} FINISH [{elapsedTime}]"); + compactionResults.Add(strategyResult); + overallAfter = currentBefore = strategyResult.After; } - readOnlyMessages = messages as IReadOnlyList ?? [.. messages]; - CompactionMetric overallAfter = this._metricsCalculator.Calculate(readOnlyMessages); + Debug.WriteLineIf(overallBefore.TokenCount != overallAfter.TokenCount, $"COMPACTION: TOTAL [{timer.Elapsed}] {overallBefore.TokenCount} => {overallAfter.TokenCount} tokens"); - return new(overallBefore, overallAfter, results); + return new(overallBefore, overallAfter, compactionResults); } } diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ChatHistoryCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ChatHistoryCompactionStrategy.cs index 2bb8e01e40..5d78bfe6e5 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ChatHistoryCompactionStrategy.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ChatHistoryCompactionStrategy.cs @@ -2,8 +2,7 @@ using System; using System.Collections.Generic; -using System.Collections.ObjectModel; -using System.Linq; +using System.Diagnostics; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.AI; @@ -21,7 +20,7 @@ namespace Microsoft.Agents.AI.Compaction; /// while the strategy adds: /// /// A conditional trigger via that decides whether compaction runs. -/// Before/after reporting via . +/// Before/after reporting via . /// /// /// @@ -36,7 +35,7 @@ namespace Microsoft.Agents.AI.Compaction; /// public abstract class ChatHistoryCompactionStrategy { - private static readonly AsyncLocal s_currentMetrics = new(); + internal static readonly AsyncLocal s_currentMetrics = new(); /// /// Initializes a new instance of the class. @@ -48,9 +47,9 @@ protected ChatHistoryCompactionStrategy(IChatReducer reducer) } /// - /// Exposes the current for the executing strategy, allowing to make informed decisions. + /// Exposes the current for the executing strategy, allowing to make informed decisions. /// - protected static CompactionMetric CurrentMetrics => s_currentMetrics.Value ?? throw new InvalidOperationException($"No active {nameof(ChatHistoryCompactionStrategy)}."); + protected static ChatHistoryMetric CurrentMetrics => s_currentMetrics.Value ?? throw new InvalidOperationException($"No active {nameof(ChatHistoryCompactionStrategy)}."); /// /// Gets the that performs the actual message compaction. @@ -72,48 +71,50 @@ protected ChatHistoryCompactionStrategy(IChatReducer reducer) /// /// to proceed with compaction; to skip. /// - public abstract bool ShouldCompact(CompactionMetric metrics); + protected abstract bool ShouldCompact(ChatHistoryMetric metrics); /// /// Execute this strategy: check the trigger, delegate to the , and report metrics. /// - /// The mutable message list to compact. + /// The mutable message list to compact. /// The calculator to use for metric snapshots. /// The to monitor for cancellation requests. /// A reporting the outcome. - public async ValueTask CompactAsync( - IList messages, + internal async ValueTask CompactAsync( + List history, IChatHistoryMetricsCalculator metricsCalculator, CancellationToken cancellationToken = default) { - messages = Throw.IfNull(messages); Throw.IfNull(metricsCalculator); + Throw.IfNull(history); - List? messageList = messages as List; - ReadOnlyCollection snapshot = messageList is not null ? messageList.AsReadOnly() : new(messages); - CompactionMetric before = metricsCalculator.Calculate(snapshot); - s_currentMetrics.Value = before; - if (!this.ShouldCompact(before)) + ChatHistoryMetric beforeMetrics = CurrentMetrics; + if (!this.ShouldCompact(beforeMetrics)) { - return CompactionResult.Skipped(this.Name, before); + // %%% VERBOSE - Debug.WriteLine($"COMPACTION: {this.Name} - Skipped"); + return CompactionResult.Skipped(this.Name, beforeMetrics); } - ChatMessage[] reduced = (await this.Reducer.ReduceAsync(snapshot, cancellationToken).ConfigureAwait(false)).ToArray(); + Debug.WriteLine($"COMPACTION: {this.Name} - Reducing"); - bool modified = reduced.Length != snapshot.Count; + IEnumerable reducerResult = await this.Reducer.ReduceAsync(history, cancellationToken).ConfigureAwait(false); + + // Ensure we have a concrete collection to avoid multiple enumerations of the reducer result, which could be costly if it's an iterator. + ChatMessage[] reducedCopy = [.. reducerResult]; + + bool modified = reducedCopy.Length != history.Count; if (modified) { - messages.Clear(); - foreach (ChatMessage message in reduced) - { - messages.Add(message); - } + history.Clear(); + history.AddRange(reducedCopy); } - CompactionMetric after = modified - ? metricsCalculator.Calculate(reduced) - : before; + ChatHistoryMetric afterMetrics = modified + ? metricsCalculator.Calculate(reducedCopy) + : beforeMetrics; + + Debug.WriteLine($"COMPACTION: {this.Name} - Tokens {beforeMetrics.TokenCount} => {afterMetrics.TokenCount}"); - return new(this.Name, applied: modified, before, after); + return new(this.Name, applied: modified, beforeMetrics, afterMetrics); } } diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/CompactionMetric.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ChatHistoryMetric.cs similarity index 97% rename from dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/CompactionMetric.cs rename to dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ChatHistoryMetric.cs index e4d641623b..f2d9694dfe 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/CompactionMetric.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ChatHistoryMetric.cs @@ -7,7 +7,7 @@ namespace Microsoft.Agents.AI.Compaction; /// /// Immutable snapshot of conversation metrics used for compaction trigger evaluation and reporting. /// -public sealed class CompactionMetric +public sealed class ChatHistoryMetric { /// /// Gets the estimated token count across all messages. diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ChatReducerCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ChatReducerCompactionStrategy.cs new file mode 100644 index 0000000000..7305e86cd5 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ChatReducerCompactionStrategy.cs @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using Microsoft.Extensions.AI; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Compaction; + +/// +/// Represents a chat history compaction strategy that uses a condition function to determine when compaction should +/// occur. +/// +/// +/// This strategy evaluates a user-provided condition against compaction metrics to decide whether to +/// compact the chat history. It is useful for scenarios where compaction should be triggered based on custom thresholds +/// or criteria. Inherits from ChatHistoryCompactionStrategy. +/// +public class ChatReducerCompactionStrategy : ChatHistoryCompactionStrategy +{ + private readonly Func _condition; + + /// + /// Initializes a new instance of the class. + /// + public ChatReducerCompactionStrategy( + IChatReducer reducer, + Func condition) + : base(reducer) + { + this._condition = Throw.IfNull(condition); + } + + /// + protected override bool ShouldCompact(ChatHistoryMetric metrics) => this._condition(metrics); +} diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/CompactionPipelineResult.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/CompactionPipelineResult.cs index ab7e6448ae..c416528a83 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/CompactionPipelineResult.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/CompactionPipelineResult.cs @@ -18,8 +18,8 @@ public sealed class CompactionPipelineResult /// Metrics of the conversation after all strategies ran. /// Per-strategy results in execution order. internal CompactionPipelineResult( - CompactionMetric before, - CompactionMetric after, + ChatHistoryMetric before, + ChatHistoryMetric after, IReadOnlyList strategyResults) { this.Before = Throw.IfNull(before); @@ -30,12 +30,12 @@ internal CompactionPipelineResult( /// /// Gets the conversation metrics before any compaction strategy ran. /// - public CompactionMetric Before { get; } + public ChatHistoryMetric Before { get; } /// /// Gets the conversation metrics after all compaction strategies ran. /// - public CompactionMetric After { get; } + public ChatHistoryMetric After { get; } /// /// Gets the per-strategy results in execution order. diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/CompactionResult.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/CompactionResult.cs index c6eca6625e..2c4b2ad13e 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/CompactionResult.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/CompactionResult.cs @@ -16,7 +16,7 @@ public sealed class CompactionResult /// Whether the strategy modified the message list. /// Metrics before the strategy ran. /// Metrics after the strategy ran. - public CompactionResult(string strategyName, bool applied, CompactionMetric before, CompactionMetric after) + public CompactionResult(string strategyName, bool applied, ChatHistoryMetric before, ChatHistoryMetric after) { this.StrategyName = Throw.IfNullOrWhitespace(strategyName); this.Applied = applied; @@ -37,12 +37,12 @@ public CompactionResult(string strategyName, bool applied, CompactionMetric befo /// /// Gets the conversation metrics before the strategy executed. /// - public CompactionMetric Before { get; } + public ChatHistoryMetric Before { get; } /// /// Gets the conversation metrics after the strategy executed. /// - public CompactionMetric After { get; } + public ChatHistoryMetric After { get; } /// /// Creates a representing a skipped strategy. @@ -50,6 +50,6 @@ public CompactionResult(string strategyName, bool applied, CompactionMetric befo /// The name of the skipped strategy. /// The current conversation metrics. /// A result indicating no compaction was applied. - internal static CompactionResult Skipped(string strategyName, CompactionMetric metrics) + internal static CompactionResult Skipped(string strategyName, ChatHistoryMetric metrics) => new(strategyName, applied: false, metrics, metrics); } diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/DefaultChatHistoryMetricsCalculator.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/DefaultChatHistoryMetricsCalculator.cs index 19b9258584..2491f99591 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/DefaultChatHistoryMetricsCalculator.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/DefaultChatHistoryMetricsCalculator.cs @@ -46,7 +46,7 @@ public DefaultChatHistoryMetricsCalculator(int charsPerToken = DefaultCharsPerTo } /// - public CompactionMetric Calculate(IReadOnlyList messages) + public ChatHistoryMetric Calculate(IReadOnlyList messages) { if (messages is null || messages.Count == 0) { diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/IChatHistoryMetricsCalculator.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/IChatHistoryMetricsCalculator.cs index 830268dc76..3c4c124444 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/IChatHistoryMetricsCalculator.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/IChatHistoryMetricsCalculator.cs @@ -9,18 +9,18 @@ namespace Microsoft.Agents.AI.Compaction; // and whether custom metrics calculators are a realistic extension point. /// -/// Computes for a list of messages. +/// Computes for a list of messages. /// /// /// Token counting is model-specific. Implementations can provide precise tokenization /// (e.g., using tiktoken or a model-specific tokenizer) or use estimation heuristics. /// -public interface IChatHistoryMetricsCalculator // %%% NEEDED ??? +public interface IChatHistoryMetricsCalculator { /// /// Compute metrics for the given messages. /// /// The messages to analyze. - /// A snapshot. - CompactionMetric Calculate(IReadOnlyList messages); + /// A snapshot. + ChatHistoryMetric Calculate(IReadOnlyList messages); } diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/SlidingWindowCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/SlidingWindowCompactionStrategy.cs index 9d8f12fc62..51b5b90a1d 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/SlidingWindowCompactionStrategy.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/SlidingWindowCompactionStrategy.cs @@ -1,6 +1,5 @@ // Copyright (c) Microsoft. All rights reserved. -using System; using System.Collections.Generic; using System.Linq; using System.Threading; @@ -44,7 +43,7 @@ public SlidingWindowCompactionStrategy(int maxTurns) } /// - public override bool ShouldCompact(CompactionMetric metrics) => + protected override bool ShouldCompact(ChatHistoryMetric metrics) => metrics.UserTurnCount > this._maxTurns; /// @@ -57,25 +56,21 @@ public Task> ReduceAsync( IEnumerable messages, CancellationToken cancellationToken = default) { - IReadOnlyList messageList = [.. messages]; + IReadOnlyList messageList = [.. messages]; // %%% PERFORMANCE IReadOnlyList groups = CurrentMetrics.Groups; // Find the group-list indices where each user turn starts - //int[] turnGroupIndices = groups.Where(group => group.Kind == ChatMessageGroupKind.UserTurn).Select(group => group.StartIndex).ToArray(); // %%% TODO - List turnGroupIndices = []; - for (int i = 0; i < groups.Count; i++) - { - if (groups[i].Kind == ChatMessageGroupKind.UserTurn) - { - turnGroupIndices.Add(i); - } - } + int[] turnGroupIndices = + [.. CurrentMetrics.Groups + .Select((group, index) => (group, index)) + .Where(t => t.group.Kind == ChatMessageGroupKind.UserTurn) + .Select(t => t.index)]; // Keep the last maxTurns user turns and everything after the first kept turn - int firstKeptTurnIndex = turnGroupIndices.Count - maxTurns; + int firstKeptTurnIndex = turnGroupIndices.Length - maxTurns; int firstKeptGroupIndex = turnGroupIndices[firstKeptTurnIndex]; - List result = new(messageList.Count); + List result = new(messageList.Count); // %%% PERFORMANCE for (int gi = 0; gi < groups.Count; gi++) { ChatMessageGroup group = groups[gi]; diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/SummarizationCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/SummarizationCompactionStrategy.cs index 433b7c0f04..028077beae 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/SummarizationCompactionStrategy.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/SummarizationCompactionStrategy.cs @@ -69,7 +69,7 @@ public SummarizationCompactionStrategy( } /// - public override bool ShouldCompact(CompactionMetric metrics) => + protected override bool ShouldCompact(ChatHistoryMetric metrics) => metrics.TokenCount > this._maxTokens; /// @@ -96,7 +96,7 @@ public async Task> ReduceAsync( IReadOnlyList messageList = [.. messages]; IReadOnlyList groups = CurrentMetrics.Groups; - List nonSystemGroups = groups.Where(g => g.Kind != ChatMessageGroupKind.System).ToList(); + List nonSystemGroups = [.. groups.Where(g => g.Kind != ChatMessageGroupKind.System)]; int protectedFromIndex = Math.Max(0, nonSystemGroups.Count - this._preserveRecentGroups); if (protectedFromIndex == 0) diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ToolResultCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ToolResultCompactionStrategy.cs index 424943262b..26380d6d6d 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ToolResultCompactionStrategy.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ToolResultCompactionStrategy.cs @@ -29,6 +29,11 @@ namespace Microsoft.Agents.AI.Compaction; /// public class ToolResultCompactionStrategy : ChatHistoryCompactionStrategy { + /// + /// The default value for `preserveRecentGroups` used when constructing . + /// + public const int DefaultPreserveRecentGroups = 2; + private readonly int _maxTokens; /// @@ -39,14 +44,14 @@ public class ToolResultCompactionStrategy : ChatHistoryCompactionStrategy /// The number of most-recent non-system message groups to protect from collapsing. /// Defaults to 2, ensuring the current turn's tool interactions remain visible. /// - public ToolResultCompactionStrategy(int maxTokens, int preserveRecentGroups = 2) + public ToolResultCompactionStrategy(int maxTokens, int preserveRecentGroups = DefaultPreserveRecentGroups) : base(new ToolResultClearingReducer(preserveRecentGroups)) { this._maxTokens = maxTokens; } /// - public override bool ShouldCompact(CompactionMetric metrics) => + protected override bool ShouldCompact(ChatHistoryMetric metrics) => metrics.TokenCount > this._maxTokens && metrics.ToolCallCount > 0; /// @@ -62,7 +67,7 @@ public Task> ReduceAsync( IReadOnlyList messageList = [.. messages]; IReadOnlyList groups = CurrentMetrics.Groups; - List nonSystemGroups = groups.Where(g => g.Kind != ChatMessageGroupKind.System).ToList(); + List nonSystemGroups = [.. groups.Where(g => g.Kind != ChatMessageGroupKind.System)]; int protectedFromIndex = Math.Max(0, nonSystemGroups.Count - preserveRecentGroups); HashSet protectedGroupStarts = []; for (int i = protectedFromIndex; i < nonSystemGroups.Count; i++) diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/TruncationCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/TruncationCompactionStrategy.cs index 1390f463fc..a2a6b07acf 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/TruncationCompactionStrategy.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/TruncationCompactionStrategy.cs @@ -41,7 +41,7 @@ public TruncationCompactionStrategy(int maxTokens, int preserveRecentGroups = 1) } /// - public override bool ShouldCompact(CompactionMetric metrics) => + protected override bool ShouldCompact(ChatHistoryMetric metrics) => metrics.TokenCount > this._maxTokens; /// @@ -56,15 +56,15 @@ public Task> ReduceAsync( { IReadOnlyList messageList = [.. messages]; - List removableGroups = CurrentMetrics.Groups.Where(g => g.Kind != ChatMessageGroupKind.System).ToList(); + ChatMessageGroup[] removableGroups = [.. CurrentMetrics.Groups.Where(g => g.Kind != ChatMessageGroupKind.System)]; - if (removableGroups.Count == 0) + if (removableGroups.Length == 0) { return Task.FromResult>(messageList); } // Remove oldest non-system groups, keeping at least preserveRecentGroups. - int maxRemovable = removableGroups.Count - preserveRecentGroups; + int maxRemovable = removableGroups.Length - preserveRecentGroups; if (maxRemovable <= 0) { diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/ChatHistoryCompactionPipelineTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/ChatHistoryCompactionPipelineTests.cs index d5d58d7ba9..75658054d1 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/ChatHistoryCompactionPipelineTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/ChatHistoryCompactionPipelineTests.cs @@ -14,11 +14,14 @@ public class ChatHistoryCompactionPipelineTests [Fact] public async Task EmptyStrategies_ReturnsUnmodifiedAsync() { + // Arrange ChatHistoryCompactionPipeline pipeline = new([]); List messages = [new(ChatRole.User, "Hello")]; + // Act CompactionPipelineResult result = await pipeline.CompactAsync(messages); + // Assert Assert.False(result.AnyApplied); Assert.Equal(1, result.Before.MessageCount); Assert.Equal(1, result.After.MessageCount); @@ -28,6 +31,7 @@ public async Task EmptyStrategies_ReturnsUnmodifiedAsync() [Fact] public async Task ChainsStrategies_InOrderAsync() { + // Arrange ChatHistoryCompactionStrategy[] strategies = [ new NeverCompactStrategy(), @@ -40,8 +44,10 @@ public async Task ChainsStrategies_InOrderAsync() new(ChatRole.User, "Second"), ]; + // Act CompactionPipelineResult result = await pipeline.CompactAsync(messages); + // Assert Assert.True(result.AnyApplied); Assert.Equal(2, result.StrategyResults.Count); Assert.False(result.StrategyResults[0].Applied); @@ -52,6 +58,7 @@ public async Task ChainsStrategies_InOrderAsync() [Fact] public async Task ReportsOverallMetricsAsync() { + // Arrange ChatHistoryCompactionPipeline pipeline = new([new RemoveFirstMessageStrategy()]); List messages = [ @@ -60,8 +67,10 @@ public async Task ReportsOverallMetricsAsync() new(ChatRole.User, "Third"), ]; + // Act CompactionPipelineResult result = await pipeline.CompactAsync(messages); + // Assert Assert.Equal(3, result.Before.MessageCount); Assert.Equal(2, result.After.MessageCount); } @@ -69,51 +78,39 @@ public async Task ReportsOverallMetricsAsync() [Fact] public async Task CustomMetricsCalculator_IsUsedAsync() { + // Arrange Moq.Mock calcMock = new(); calcMock .Setup(c => c.Calculate(Moq.It.IsAny>())) - .Returns(new CompactionMetric { MessageCount = 42 }); - + .Returns(new ChatHistoryMetric { MessageCount = 42 }); ChatHistoryCompactionPipeline pipeline = new(calcMock.Object, []); List messages = [new(ChatRole.User, "Hello")]; + // Act CompactionPipelineResult result = await pipeline.CompactAsync(messages); + // Assert Assert.Equal(42, result.Before.MessageCount); - calcMock.Verify(c => c.Calculate(Moq.It.IsAny>()), Moq.Times.AtLeast(2)); - } - - [Fact] - public async Task CompactAsync_NonReadOnlyListMessages_WorksAsync() - { - ChatHistoryCompactionPipeline pipeline = new([new RemoveFirstMessageStrategy()]); - NonReadOnlyList messages = new( - [ - new(ChatRole.User, "First"), - new(ChatRole.User, "Second"), - ]); - - CompactionPipelineResult result = await pipeline.CompactAsync(messages); - - Assert.True(result.AnyApplied); - Assert.Single(messages); + calcMock.Verify(c => c.Calculate(Moq.It.IsAny>()), Moq.Times.Once); } [Fact] public async Task ReduceAsync_DelegatesCompactionAsync() { + // Arrange ChatHistoryCompactionPipeline pipeline = new([new RemoveFirstMessageStrategy()]); - - ChatMessage[] messages = + List messages = [ new(ChatRole.User, "First"), new(ChatRole.User, "Second"), new(ChatRole.User, "Third"), ]; - IEnumerable result = await ((IChatReducer)pipeline).ReduceAsync(messages, default); + // Act + IEnumerable result = await pipeline.ReduceAsync(messages, default); List resultList = result.ToList(); + // Assert Assert.Equal(2, resultList.Count); Assert.Equal("Second", resultList[0].Text); Assert.Equal("Third", resultList[1].Text); @@ -122,16 +119,18 @@ public async Task ReduceAsync_DelegatesCompactionAsync() [Fact] public async Task ReduceAsync_EmptyStrategies_ReturnsAllMessagesAsync() { + // Arrange ChatHistoryCompactionPipeline pipeline = new([]); - ChatMessage[] messages = [ new(ChatRole.User, "Hello"), new(ChatRole.User, "World"), ]; - IEnumerable result = await ((IChatReducer)pipeline).ReduceAsync(messages, default); + // Act + IEnumerable result = await pipeline.ReduceAsync(messages, default); + // Assert Assert.Equal(2, result.Count()); } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/ChatHistoryCompactionStrategyTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/ChatHistoryCompactionStrategyTests.cs index 5c2e006350..a0635de76e 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/ChatHistoryCompactionStrategyTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/ChatHistoryCompactionStrategyTests.cs @@ -17,28 +17,32 @@ public class ChatHistoryCompactionStrategyTests [Fact] public async Task ShouldCompactReturnsFalse_SkipsAsync() { - NeverCompactStrategy strategy = new(); + // Arrange List messages = [new(ChatRole.User, "Hello")]; - DefaultChatHistoryMetricsCalculator calculator = new(); + NeverCompactStrategy strategy = new(); - CompactionResult result = await strategy.CompactAsync(messages, calculator); + // Act + CompactionResult result = await RunCompactionStrategyAsync(strategy, messages); + // Assert Assert.False(result.Applied); } [Fact] public async Task ShouldCompactReturnsTrue_RunsCompactionAsync() { - RemoveFirstMessageStrategy strategy = new(); + // Arrange List messages = [ new(ChatRole.User, "First"), new(ChatRole.User, "Second"), ]; - DefaultChatHistoryMetricsCalculator calculator = new(); + RemoveFirstMessageStrategy strategy = new(); - CompactionResult result = await strategy.CompactAsync(messages, calculator); + // Act + CompactionResult result = await RunCompactionStrategyAsync(strategy, messages); + // Assert Assert.True(result.Applied); Assert.Single(messages); Assert.Equal("Second", messages[0].Text); @@ -47,23 +51,24 @@ public async Task ShouldCompactReturnsTrue_RunsCompactionAsync() } [Fact] - public async Task DelegatesToIChatReducerAsync() + public async Task DelegatesToReducerAsync() { - Mock reducerMock = new(); - reducerMock - .Setup(r => r.ReduceAsync(It.IsAny>(), It.IsAny())) - .ReturnsAsync((IEnumerable msgs, CancellationToken _) => msgs.Skip(1)); - - TestCompactionStrategy strategy = new(reducerMock.Object); + // Arrange List messages = [ new(ChatRole.User, "First"), new(ChatRole.User, "Second"), ]; - DefaultChatHistoryMetricsCalculator calculator = new(); + Mock reducerMock = new(); + reducerMock + .Setup(r => r.ReduceAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync((IEnumerable messages, CancellationToken _) => messages.Skip(1)); + TestCompactionStrategy strategy = new(reducerMock.Object); - CompactionResult result = await strategy.CompactAsync(messages, calculator); + // Act + CompactionResult result = await RunCompactionStrategyAsync(strategy, messages); + // Assert Assert.True(result.Applied); Assert.Single(messages); Assert.Equal("Second", messages[0].Text); @@ -73,86 +78,53 @@ public async Task DelegatesToIChatReducerAsync() [Fact] public async Task ReducerNoChange_ReturnsFalseAsync() { - Mock reducerMock = new(); - reducerMock - .Setup(r => r.ReduceAsync(It.IsAny>(), It.IsAny())) - .ReturnsAsync((IEnumerable msgs, CancellationToken _) => msgs); - - TestCompactionStrategy strategy = new(reducerMock.Object); + // Arrange List messages = [ new(ChatRole.User, "Hello"), ]; - DefaultChatHistoryMetricsCalculator calculator = new(); + Mock reducerMock = new(); + reducerMock + .Setup(r => r.ReduceAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync((IEnumerable msgs, CancellationToken _) => msgs); + TestCompactionStrategy strategy = new(reducerMock.Object, shouldCompact: false); - CompactionResult result = await strategy.CompactAsync(messages, calculator); + // Act + CompactionResult result = await RunCompactionStrategyAsync(strategy, messages); + // Assert Assert.False(result.Applied); Assert.Single(messages); } [Fact] - public void ExposesReducer() + public void ReducerLifecycle() { + // Arrange Mock reducerMock = new(); - TestCompactionStrategy strategy = new(reducerMock.Object); - Assert.Same(reducerMock.Object, strategy.Reducer); - } - - [Fact] - public void DefaultName_IsReducerTypeName() - { - Mock reducerMock = new(); + // Act TestCompactionStrategy strategy = new(reducerMock.Object); - // Moq proxy type name is used since we're using a mock + // Assert + Assert.Same(reducerMock.Object, strategy.Reducer); Assert.NotNull(strategy.Name); Assert.NotEmpty(strategy.Name); + Assert.Equal(reducerMock.Object.GetType().Name, strategy.Name); } [Fact] - public void ConditionDelegate_ReturnsTrue_ShouldCompactReturnsTrue() - { - Mock reducerMock = new(); - TestCompactionStrategy strategy = new(reducerMock.Object); - - CompactionMetric metrics = new() { TokenCount = 100 }; - Assert.True(strategy.ShouldCompact(metrics)); - } - - [Fact] - public void ConditionDelegate_ReturnsFalse_ShouldCompactReturnsFalse() - { - Mock reducerMock = new(); - TestCompactionStrategy strategy = new(reducerMock.Object, shouldCompact: false); - - CompactionMetric metrics = new() { TokenCount = 100 }; - Assert.False(strategy.ShouldCompact(metrics)); - } - - [Fact] - public async Task CompactAsync_NonReadOnlyListMessages_WorksAsync() + public void CurrentMetrics_OutsideStrategy_Throws() { - RemoveFirstMessageStrategy strategy = new(); - NonReadOnlyList messages = new( - [ - new(ChatRole.User, "First"), - new(ChatRole.User, "Second"), - ]); - DefaultChatHistoryMetricsCalculator calculator = new(); - - CompactionResult result = await strategy.CompactAsync(messages, calculator); - - Assert.True(result.Applied); - Assert.Single(messages); - Assert.Equal("Second", messages[0].Text); + // Act & Assert + Assert.Throws(() => TestCompactionStrategy.GetCurrentMetrics()); } - [Fact] - public void CurrentMetrics_OutsideStrategy_Throws() + public static async ValueTask RunCompactionStrategyAsync(ChatHistoryCompactionStrategy strategy, List messages) { - Assert.Throws(() => TestCompactionStrategy.GetCurrentMetrics()); + // Act + ChatHistoryCompactionStrategy.s_currentMetrics.Value = DefaultChatHistoryMetricsCalculator.Instance.Calculate(messages); + return await strategy.CompactAsync(messages, DefaultChatHistoryMetricsCalculator.Instance); } private sealed class TestCompactionStrategy : ChatHistoryCompactionStrategy @@ -165,8 +137,8 @@ public TestCompactionStrategy(IChatReducer reducer, bool shouldCompact = true) this._shouldCompact = shouldCompact; } - public override bool ShouldCompact(CompactionMetric metrics) => this._shouldCompact; + protected override bool ShouldCompact(ChatHistoryMetric metrics) => this._shouldCompact; - public static CompactionMetric GetCurrentMetrics() => CurrentMetrics; + public static ChatHistoryMetric GetCurrentMetrics() => CurrentMetrics; } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/ChatReducerCompactionStrategyTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/ChatReducerCompactionStrategyTests.cs new file mode 100644 index 0000000000..844f7f55ea --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/ChatReducerCompactionStrategyTests.cs @@ -0,0 +1,114 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Compaction; +using Microsoft.Extensions.AI; +using Moq; + +namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction; + +public class ChatReducerCompactionStrategyTests : CompactionStrategyTestBase +{ + [Fact] + public async Task ConditionFalse_SkipsAsync() + { + // Arrange + List messages = [new(ChatRole.User, "Hello")]; + Mock reducerMock = new(); + ChatReducerCompactionStrategy strategy = new(reducerMock.Object, _ => false); + + // Act & Assert + await RunCompactionStrategySkippedAsync(strategy, messages); + + // Assert + reducerMock.Verify( + r => r.ReduceAsync(It.IsAny>(), It.IsAny()), + Times.Never); + } + + [Fact] + public async Task ConditionTrue_RunsReducerAsync() + { + // Arrange + List messages = + [ + new(ChatRole.User, "First"), + new(ChatRole.User, "Second"), + ]; + Mock reducerMock = new(); + reducerMock + .Setup(r => r.ReduceAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync((IEnumerable msgs, CancellationToken _) => msgs.Skip(1)); + ChatReducerCompactionStrategy strategy = new(reducerMock.Object, _ => true); + + // Act & Assert + await RunCompactionStrategyReducedAsync(strategy, messages, expectedCount: 1); + + // Assert + Assert.Equal("Second", messages[0].Text); + reducerMock.Verify( + r => r.ReduceAsync(It.IsAny>(), It.IsAny()), + Times.Once); + } + + [Fact] + public async Task ConditionReceivesMetricsAsync() + { + // Arrange + List messages = + [ + new(ChatRole.User, "Hello"), + new(ChatRole.Assistant, "Hi"), + ]; + ChatHistoryMetric? capturedMetrics = null; + Mock reducerMock = new(); + reducerMock + .Setup(r => r.ReduceAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync((IEnumerable msgs, CancellationToken _) => msgs); + ChatReducerCompactionStrategy strategy = new( + reducerMock.Object, + metrics => + { + capturedMetrics = metrics; + return false; + }); + + // Act & Assert + await RunCompactionStrategySkippedAsync(strategy, messages); + + // Assert + Assert.NotNull(capturedMetrics); + Assert.Equal(2, capturedMetrics!.MessageCount); + } + + [Fact] + public async Task ReducerNoChange_AppliedFalseAsync() + { + // Arrange + List messages = [new(ChatRole.User, "Hello")]; + Mock reducerMock = new(); + reducerMock + .Setup(r => r.ReduceAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync((IEnumerable msgs, CancellationToken _) => msgs); + ChatReducerCompactionStrategy strategy = new(reducerMock.Object, _ => true); + + // Act & Assert + await RunCompactionStrategySkippedAsync(strategy, messages); + } + + [Fact] + public void Name_ReturnsReducerTypeName() + { + // Arrange + Mock reducerMock = new(); + + // Act + ChatReducerCompactionStrategy strategy = new(reducerMock.Object, _ => true); + + // Assert + Assert.Equal(reducerMock.Object.GetType().Name, strategy.Name); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/CompactionMetricTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/CompactionMetricTests.cs index c2287512cd..ace35e3607 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/CompactionMetricTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/CompactionMetricTests.cs @@ -9,7 +9,10 @@ public class CompactionMetricTests [Fact] public void DefaultValues_AreZero() { - CompactionMetric metrics = new(); + // Arrange & Act + ChatHistoryMetric metrics = new(); + + // Assert Assert.Equal(0, metrics.TokenCount); Assert.Equal(0L, metrics.ByteCount); Assert.Equal(0, metrics.MessageCount); @@ -21,7 +24,8 @@ public void DefaultValues_AreZero() [Fact] public void InitProperties_SetCorrectly() { - CompactionMetric metrics = new() + // Arrange & Act + ChatHistoryMetric metrics = new() { TokenCount = 100, ByteCount = 500, @@ -30,6 +34,7 @@ public void InitProperties_SetCorrectly() UserTurnCount = 3 }; + // Assert Assert.Equal(100, metrics.TokenCount); Assert.Equal(500L, metrics.ByteCount); Assert.Equal(5, metrics.MessageCount); diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/CompactionPipelineResultTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/CompactionPipelineResultTests.cs index 6921bc26ca..7edc8426f5 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/CompactionPipelineResultTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/CompactionPipelineResultTests.cs @@ -11,13 +11,16 @@ public class CompactionPipelineResultTests [Fact] public void Properties_AreReadable() { - CompactionMetric before = new() { MessageCount = 10 }; - CompactionMetric after = new() { MessageCount = 5 }; + // Arrange + ChatHistoryMetric before = new() { MessageCount = 10 }; + ChatHistoryMetric after = new() { MessageCount = 5 }; CompactionResult strategyResult = new("Test", applied: true, before, after); List results = [strategyResult]; + // Act CompactionPipelineResult pipelineResult = new(before, after, results); + // Assert Assert.Same(before, pipelineResult.Before); Assert.Same(after, pipelineResult.After); Assert.Single(pipelineResult.StrategyResults); @@ -26,21 +29,29 @@ public void Properties_AreReadable() [Fact] public void AnyApplied_AllFalse_ReturnsFalse() { - CompactionMetric metrics = new() { MessageCount = 5 }; + // Arrange + ChatHistoryMetric metrics = new() { MessageCount = 5 }; CompactionResult skipped = CompactionResult.Skipped("Skip", metrics); + + // Act CompactionPipelineResult result = new(metrics, metrics, [skipped]); + // Assert Assert.False(result.AnyApplied); } [Fact] public void AnyApplied_SomeTrue_ReturnsTrue() { - CompactionMetric before = new() { MessageCount = 10 }; - CompactionMetric after = new() { MessageCount = 5 }; + // Arrange + ChatHistoryMetric before = new() { MessageCount = 10 }; + ChatHistoryMetric after = new() { MessageCount = 5 }; CompactionResult applied = new("Applied", applied: true, before, after); + + // Act CompactionPipelineResult result = new(before, after, [applied]); + // Assert Assert.True(result.AnyApplied); } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/CompactionResultTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/CompactionResultTests.cs index 1bb476786b..bc36898a4d 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/CompactionResultTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/CompactionResultTests.cs @@ -9,9 +9,13 @@ public class CompactionResultTests [Fact] public void Skipped_HasSameBeforeAndAfter() { - CompactionMetric metrics = new() { MessageCount = 5, TokenCount = 100 }; + // Arrange + ChatHistoryMetric metrics = new() { MessageCount = 5, TokenCount = 100 }; + + // Act CompactionResult result = CompactionResult.Skipped("Test", metrics); + // Assert Assert.Equal("Test", result.StrategyName); Assert.False(result.Applied); Assert.Same(metrics, result.Before); diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/CompactionStrategyTestBase.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/CompactionStrategyTestBase.cs new file mode 100644 index 0000000000..851713cc83 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/CompactionStrategyTestBase.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Compaction; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction; + +public abstract class CompactionStrategyTestBase +{ + public static async ValueTask RunCompactionStrategyReducedAsync(ChatHistoryCompactionStrategy strategy, List messages, int expectedCount) + { + // Act + ChatHistoryCompactionStrategy.s_currentMetrics.Value = DefaultChatHistoryMetricsCalculator.Instance.Calculate(messages); + CompactionResult result = await strategy.CompactAsync(messages, DefaultChatHistoryMetricsCalculator.Instance); + + // Assert + Assert.True(result.Applied); + Assert.NotEqual(result.Before, result.After); + Assert.Equal(expectedCount, messages.Count); + + return result; + } + + public static async ValueTask RunCompactionStrategySkippedAsync(ChatHistoryCompactionStrategy strategy, List messages) + { + // Act + int initialCount = messages.Count; + ChatHistoryCompactionStrategy.s_currentMetrics.Value = DefaultChatHistoryMetricsCalculator.Instance.Calculate(messages); + CompactionResult result = await strategy.CompactAsync(messages, DefaultChatHistoryMetricsCalculator.Instance); + + // Assert + Assert.False(result.Applied); + Assert.Equal(result.Before, result.After); + Assert.Equal(initialCount, messages.Count); + + return result; + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/DefaultChatHistoryMetricsCalculatorTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/DefaultChatHistoryMetricsCalculatorTests.cs index 01387f1eed..2edbe6a4ff 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/DefaultChatHistoryMetricsCalculatorTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/DefaultChatHistoryMetricsCalculatorTests.cs @@ -11,9 +11,13 @@ public class DefaultChatHistoryMetricsCalculatorTests [Fact] public void EmptyList_ReturnsZeroMetrics() { + // Arrange DefaultChatHistoryMetricsCalculator calculator = new(); - CompactionMetric metrics = calculator.Calculate([]); + // Act + ChatHistoryMetric metrics = calculator.Calculate([]); + + // Assert Assert.Equal(0, metrics.TokenCount); Assert.Equal(0L, metrics.ByteCount); Assert.Equal(0, metrics.MessageCount); @@ -24,6 +28,7 @@ public void EmptyList_ReturnsZeroMetrics() [Fact] public void CountsMessages() { + // Arrange DefaultChatHistoryMetricsCalculator calculator = new(); List messages = [ @@ -31,14 +36,17 @@ public void CountsMessages() new(ChatRole.Assistant, "Hi there"), ]; - CompactionMetric metrics = calculator.Calculate(messages); + // Act + ChatHistoryMetric metrics = calculator.Calculate(messages); + // Assert Assert.Equal(2, metrics.MessageCount); } [Fact] public void CountsUserTurns() { + // Arrange DefaultChatHistoryMetricsCalculator calculator = new(); List messages = [ @@ -48,14 +56,17 @@ public void CountsUserTurns() new(ChatRole.Assistant, "Good"), ]; - CompactionMetric metrics = calculator.Calculate(messages); + // Act + ChatHistoryMetric metrics = calculator.Calculate(messages); + // Assert Assert.Equal(2, metrics.UserTurnCount); } [Fact] public void CountsToolCalls() { + // Arrange DefaultChatHistoryMetricsCalculator calculator = new(); ChatMessage assistantMsg = new(ChatRole.Assistant, [ new FunctionCallContent("call1", "get_weather", new Dictionary { ["city"] = "NYC" }), @@ -67,14 +78,17 @@ public void CountsToolCalls() assistantMsg, ]; - CompactionMetric metrics = calculator.Calculate(messages); + // Act + ChatHistoryMetric metrics = calculator.Calculate(messages); + // Assert Assert.Equal(2, metrics.ToolCallCount); } [Fact] public void ConsecutiveUserMessages_CountAsOneTurn() { + // Arrange DefaultChatHistoryMetricsCalculator calculator = new(); List messages = [ @@ -83,22 +97,27 @@ public void ConsecutiveUserMessages_CountAsOneTurn() new(ChatRole.Assistant, "Reply"), ]; - CompactionMetric metrics = calculator.Calculate(messages); + // Act + ChatHistoryMetric metrics = calculator.Calculate(messages); + // Assert Assert.Equal(1, metrics.UserTurnCount); } [Fact] public void TokenCount_IsPositive() { + // Arrange DefaultChatHistoryMetricsCalculator calculator = new(); List messages = [ new(ChatRole.User, "Hello world"), ]; - CompactionMetric metrics = calculator.Calculate(messages); + // Act + ChatHistoryMetric metrics = calculator.Calculate(messages); + // Assert Assert.True(metrics.TokenCount > 0); Assert.True(metrics.ByteCount > 0); } @@ -106,9 +125,13 @@ public void TokenCount_IsPositive() [Fact] public void NullInput_ReturnsZeroMetrics() { + // Arrange DefaultChatHistoryMetricsCalculator calculator = new(); - CompactionMetric metrics = calculator.Calculate(null!); + // Act + ChatHistoryMetric metrics = calculator.Calculate(null!); + + // Assert Assert.Equal(0, metrics.TokenCount); Assert.Equal(0L, metrics.ByteCount); Assert.Equal(0, metrics.MessageCount); @@ -118,45 +141,50 @@ public void NullInput_ReturnsZeroMetrics() [Fact] public void InvalidCharsPerToken_UsesDefault() { - // A non-positive charsPerToken should fall back to the default (4) + // Arrange DefaultChatHistoryMetricsCalculator calculator = new(charsPerToken: 0); List messages = [ new(ChatRole.User, "Hello world"), ]; - CompactionMetric metrics = calculator.Calculate(messages); + // Act + ChatHistoryMetric metrics = calculator.Calculate(messages); - // With default 4 chars/token: "Hello world" = 11 chars → 11/4=2 + 4 overhead = 6 tokens + // Assert Assert.True(metrics.TokenCount > 0); } [Fact] public void NullMessageText_HandledGracefully() { + // Arrange DefaultChatHistoryMetricsCalculator calculator = new(); - // Message with no text content — Text returns null ChatMessage msg = new() { Role = ChatRole.User }; List messages = [msg]; - CompactionMetric metrics = calculator.Calculate(messages); + // Act + ChatHistoryMetric metrics = calculator.Calculate(messages); + // Assert Assert.Equal(1, metrics.MessageCount); - // Null text → empty string → 0 bytes, only overhead tokens - Assert.True(metrics.TokenCount > 0); // per-message overhead + Assert.True(metrics.TokenCount > 0); Assert.Equal(0L, metrics.ByteCount); } [Fact] public void NullContents_SkipsToolCounting() { + // Arrange DefaultChatHistoryMetricsCalculator calculator = new(); ChatMessage msg = new(ChatRole.User, "text"); msg.Contents = null!; List messages = [msg]; - CompactionMetric metrics = calculator.Calculate(messages); + // Act + ChatHistoryMetric metrics = calculator.Calculate(messages); + // Assert Assert.Equal(1, metrics.MessageCount); Assert.Equal(0, metrics.ToolCallCount); } @@ -164,16 +192,18 @@ public void NullContents_SkipsToolCounting() [Fact] public void MessageWithOnlyNonTextContent_NullTextHandled() { + // Arrange DefaultChatHistoryMetricsCalculator calculator = new(); - // FunctionCallContent-only message has null Text ChatMessage msg = new(ChatRole.Assistant, [ new FunctionCallContent("c1", "func"), ]); List messages = [msg]; - CompactionMetric metrics = calculator.Calculate(messages); + // Act + ChatHistoryMetric metrics = calculator.Calculate(messages); + // Assert Assert.Equal(1, metrics.MessageCount); Assert.Equal(1, metrics.ToolCallCount); } @@ -181,6 +211,7 @@ public void MessageWithOnlyNonTextContent_NullTextHandled() [Fact] public void Calculate_PopulatesGroupIndex() { + // Arrange DefaultChatHistoryMetricsCalculator calculator = new(); List messages = [ @@ -189,8 +220,10 @@ public void Calculate_PopulatesGroupIndex() new(ChatRole.Assistant, "Hi there"), ]; - CompactionMetric metrics = calculator.Calculate(messages); + // Act + ChatHistoryMetric metrics = calculator.Calculate(messages); + // Assert Assert.Equal(3, metrics.Groups.Count); Assert.Equal(ChatMessageGroupKind.System, metrics.Groups[0].Kind); Assert.Equal(ChatMessageGroupKind.UserTurn, metrics.Groups[1].Kind); @@ -200,23 +233,30 @@ public void Calculate_PopulatesGroupIndex() [Fact] public void EmptyList_GroupIndexIsEmpty() { + // Arrange DefaultChatHistoryMetricsCalculator calculator = new(); - CompactionMetric metrics = calculator.Calculate([]); + // Act + ChatHistoryMetric metrics = calculator.Calculate([]); + + // Assert Assert.Empty(metrics.Groups); } [Fact] public void GroupIndex_SystemMessage_IdentifiedCorrectly() { + // Arrange DefaultChatHistoryMetricsCalculator calculator = new(); List messages = [ new(ChatRole.System, "You are a helpful assistant"), ]; + // Act IReadOnlyList groups = calculator.Calculate(messages).Groups; + // Assert Assert.Single(groups); Assert.Equal(ChatMessageGroupKind.System, groups[0].Kind); Assert.Equal(0, groups[0].StartIndex); @@ -226,6 +266,7 @@ public void GroupIndex_SystemMessage_IdentifiedCorrectly() [Fact] public void GroupIndex_AssistantWithToolCalls_GroupedWithResults() { + // Arrange DefaultChatHistoryMetricsCalculator calculator = new(); ChatMessage assistantMsg = new(ChatRole.Assistant, [ new FunctionCallContent("call1", "get_weather", new Dictionary { ["city"] = "NYC" }), @@ -240,18 +281,21 @@ public void GroupIndex_AssistantWithToolCalls_GroupedWithResults() toolResult, ]; + // Act IReadOnlyList groups = calculator.Calculate(messages).Groups; + // Assert Assert.Equal(2, groups.Count); Assert.Equal(ChatMessageGroupKind.UserTurn, groups[0].Kind); Assert.Equal(ChatMessageGroupKind.AssistantToolGroup, groups[1].Kind); Assert.Equal(1, groups[1].StartIndex); - Assert.Equal(2, groups[1].Count); // assistant + tool result + Assert.Equal(2, groups[1].Count); } [Fact] public void GroupIndex_MultipleToolResults_GroupedTogether() { + // Arrange DefaultChatHistoryMetricsCalculator calculator = new(); ChatMessage assistantMsg = new(ChatRole.Assistant, [ new FunctionCallContent("c1", "func1"), @@ -261,8 +305,10 @@ public void GroupIndex_MultipleToolResults_GroupedTogether() ChatMessage tool2 = new(ChatRole.Tool, [new FunctionResultContent("c2", "result2")]); List messages = [assistantMsg, tool1, tool2]; + // Act IReadOnlyList groups = calculator.Calculate(messages).Groups; + // Assert Assert.Single(groups); Assert.Equal(ChatMessageGroupKind.AssistantToolGroup, groups[0].Kind); Assert.Equal(3, groups[0].Count); @@ -271,6 +317,7 @@ public void GroupIndex_MultipleToolResults_GroupedTogether() [Fact] public void GroupIndex_ComplexConversation_CorrectGrouping() { + // Arrange DefaultChatHistoryMetricsCalculator calculator = new(); List messages = [ @@ -283,29 +330,34 @@ public void GroupIndex_ComplexConversation_CorrectGrouping() new(ChatRole.Assistant, "It's sunny!"), ]; + // Act IReadOnlyList groups = calculator.Calculate(messages).Groups; + // Assert Assert.Equal(6, groups.Count); Assert.Equal(ChatMessageGroupKind.System, groups[0].Kind); Assert.Equal(ChatMessageGroupKind.UserTurn, groups[1].Kind); Assert.Equal(ChatMessageGroupKind.AssistantPlain, groups[2].Kind); Assert.Equal(ChatMessageGroupKind.UserTurn, groups[3].Kind); Assert.Equal(ChatMessageGroupKind.AssistantToolGroup, groups[4].Kind); - Assert.Equal(2, groups[4].Count); // assistant + tool + Assert.Equal(2, groups[4].Count); Assert.Equal(ChatMessageGroupKind.AssistantPlain, groups[5].Kind); } [Fact] public void GroupIndex_OrphanToolResult_IdentifiedCorrectly() { + // Arrange DefaultChatHistoryMetricsCalculator calculator = new(); List messages = [ new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c1", "orphan result")]), ]; + // Act IReadOnlyList groups = calculator.Calculate(messages).Groups; + // Assert Assert.Single(groups); Assert.Equal(ChatMessageGroupKind.ToolResult, groups[0].Kind); } @@ -313,14 +365,17 @@ public void GroupIndex_OrphanToolResult_IdentifiedCorrectly() [Fact] public void GroupIndex_UnknownRole_IdentifiedAsOther() { + // Arrange DefaultChatHistoryMetricsCalculator calculator = new(); List messages = [ new(new ChatRole("custom"), "custom message"), ]; + // Act IReadOnlyList groups = calculator.Calculate(messages).Groups; + // Assert Assert.Single(groups); Assert.Equal(ChatMessageGroupKind.Other, groups[0].Kind); } @@ -328,13 +383,16 @@ public void GroupIndex_UnknownRole_IdentifiedAsOther() [Fact] public void GroupIndex_AssistantWithNullContents_ClassifiedAsPlain() { + // Arrange DefaultChatHistoryMetricsCalculator calculator = new(); ChatMessage msg = new(ChatRole.Assistant, "reply"); msg.Contents = null!; List messages = [msg]; + // Act IReadOnlyList groups = calculator.Calculate(messages).Groups; + // Assert Assert.Single(groups); Assert.Equal(ChatMessageGroupKind.AssistantPlain, groups[0].Kind); } diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/Internal/NeverCompactStrategy.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/Internal/NeverCompactStrategy.cs index 9a4429e08a..f126ed5db2 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/Internal/NeverCompactStrategy.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/Internal/NeverCompactStrategy.cs @@ -16,7 +16,8 @@ public NeverCompactStrategy() } public override string Name => "NeverCompact"; - public override bool ShouldCompact(CompactionMetric metrics) => false; + + protected override bool ShouldCompact(ChatHistoryMetric metrics) => false; private sealed class NoOpReducer : IChatReducer { diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/Internal/NonReadOnlyList.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/Internal/NonReadOnlyList.cs deleted file mode 100644 index 101388c921..0000000000 --- a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/Internal/NonReadOnlyList.cs +++ /dev/null @@ -1,40 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Collections; -using System.Collections.Generic; -using System.Linq; - -namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction.Internal; - -/// -/// An IList<T> that does NOT implement IReadOnlyList<T>, -/// used to test the defensive as IReadOnlyList<T> ?? fallback patterns. -/// -internal sealed class NonReadOnlyList : IList -{ - private readonly List _inner; - - public NonReadOnlyList(IEnumerable items) - { - this._inner = items.ToList(); - } - - public T this[int index] - { - get => this._inner[index]; - set => this._inner[index] = value; - } - - public int Count => this._inner.Count; - public bool IsReadOnly => false; - public void Add(T item) => this._inner.Add(item); - public void Clear() => this._inner.Clear(); - public bool Contains(T item) => this._inner.Contains(item); - public void CopyTo(T[] array, int arrayIndex) => this._inner.CopyTo(array, arrayIndex); - public IEnumerator GetEnumerator() => this._inner.GetEnumerator(); - public int IndexOf(T item) => this._inner.IndexOf(item); - public void Insert(int index, T item) => this._inner.Insert(index, item); - public bool Remove(T item) => this._inner.Remove(item); - public void RemoveAt(int index) => this._inner.RemoveAt(index); - IEnumerator IEnumerable.GetEnumerator() => this.GetEnumerator(); -} diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/Internal/RemoveFirstMessageStrategy.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/Internal/RemoveFirstMessageStrategy.cs index f2ca36bc10..477a7815a0 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/Internal/RemoveFirstMessageStrategy.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/Internal/RemoveFirstMessageStrategy.cs @@ -18,7 +18,7 @@ public RemoveFirstMessageStrategy() public override string Name => "RemoveFirst"; - public override bool ShouldCompact(CompactionMetric metrics) => metrics.MessageCount > 0; + protected override bool ShouldCompact(ChatHistoryMetric metrics) => metrics.MessageCount > 0; private sealed class RemoveFirstReducer : IChatReducer { diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/MessageGroupTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/MessageGroupTests.cs index 5449b4bbf9..c84ebbb1ac 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/MessageGroupTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/MessageGroupTests.cs @@ -9,10 +9,12 @@ public class MessageGroupTests [Fact] public void Equality_Works() { + // Arrange ChatMessageGroup a = new(0, 2, ChatMessageGroupKind.AssistantToolGroup); ChatMessageGroup b = new(0, 2, ChatMessageGroupKind.AssistantToolGroup); ChatMessageGroup c = new(1, 2, ChatMessageGroupKind.AssistantToolGroup); + // Act & Assert Assert.Equal(a, b); Assert.True(a == b); Assert.NotEqual(a, c); @@ -22,34 +24,42 @@ public void Equality_Works() [Fact] public void Equals_Object_NullReturnsFalse() { + // Arrange ChatMessageGroup group = new(0, 1, ChatMessageGroupKind.System); + // Act & Assert Assert.False(group.Equals(null)); } [Fact] public void Equals_Object_BoxedMessageGroupReturnsTrue() { + // Arrange ChatMessageGroup group = new(0, 2, ChatMessageGroupKind.AssistantToolGroup); object boxed = new ChatMessageGroup(0, 2, ChatMessageGroupKind.AssistantToolGroup); + // Act & Assert Assert.True(group.Equals(boxed)); } [Fact] public void Equals_Object_WrongTypeReturnsFalse() { + // Arrange ChatMessageGroup group = new(0, 1, ChatMessageGroupKind.System); + // Act & Assert Assert.False(group.Equals("not a MessageGroup")); } [Fact] public void GetHashCode_ConsistentForEqualInstances() { + // Arrange ChatMessageGroup a = new(0, 2, ChatMessageGroupKind.AssistantToolGroup); ChatMessageGroup b = new(0, 2, ChatMessageGroupKind.AssistantToolGroup); + // Act & Assert Assert.Equal(a.GetHashCode(), b.GetHashCode()); } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/SlidingWindowCompactionStrategyTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/SlidingWindowCompactionStrategyTests.cs index dee020130b..b3e75920ba 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/SlidingWindowCompactionStrategyTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/SlidingWindowCompactionStrategyTests.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System.Collections.Generic; using System.Threading.Tasks; @@ -7,47 +7,27 @@ namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction; -public class SlidingWindowCompactionStrategyTests +public class SlidingWindowCompactionStrategyTests : CompactionStrategyTestBase { - [Fact] - public void ShouldCompact_UnderLimit_ReturnsFalse() - { - SlidingWindowCompactionStrategy strategy = new(maxTurns: 10); - CompactionMetric metrics = new() { UserTurnCount = 3 }; - - Assert.False(strategy.ShouldCompact(metrics)); - } - - [Fact] - public void ShouldCompact_OverLimit_ReturnsTrue() - { - SlidingWindowCompactionStrategy strategy = new(maxTurns: 2); - CompactionMetric metrics = new() { UserTurnCount = 5 }; - - Assert.True(strategy.ShouldCompact(metrics)); - } - [Fact] public async Task UnderLimit_NoChangeAsync() { - SlidingWindowCompactionStrategy strategy = new(maxTurns: 10); + // Arrange List messages = [ new(ChatRole.User, "Hello"), new(ChatRole.Assistant, "Hi"), ]; - DefaultChatHistoryMetricsCalculator calculator = new(); - - CompactionResult result = await strategy.CompactAsync(messages, calculator); + SlidingWindowCompactionStrategy strategy = new(maxTurns: 10); - Assert.False(result.Applied); - Assert.Equal(2, messages.Count); + // Act & Assert + await RunCompactionStrategySkippedAsync(strategy, messages); } [Fact] public async Task KeepsLastNTurnsAsync() { - SlidingWindowCompactionStrategy strategy = new(maxTurns: 2); + // Arrange List messages = [ new(ChatRole.User, "Turn 1"), @@ -57,12 +37,12 @@ public async Task KeepsLastNTurnsAsync() new(ChatRole.User, "Turn 3"), new(ChatRole.Assistant, "Reply 3"), ]; - DefaultChatHistoryMetricsCalculator calculator = new(); + SlidingWindowCompactionStrategy strategy = new(maxTurns: 2); - CompactionResult result = await strategy.CompactAsync(messages, calculator); + // Act & Assert + await RunCompactionStrategyReducedAsync(strategy, messages, expectedCount: 4); - Assert.True(result.Applied); - Assert.Equal(4, messages.Count); + // Assert Assert.Equal("Turn 2", messages[0].Text); Assert.Equal("Reply 2", messages[1].Text); Assert.Equal("Turn 3", messages[2].Text); @@ -72,7 +52,7 @@ public async Task KeepsLastNTurnsAsync() [Fact] public async Task PreservesSystemMessagesAsync() { - SlidingWindowCompactionStrategy strategy = new(maxTurns: 1); + // Arrange List messages = [ new(ChatRole.System, "You are a helper"), @@ -81,12 +61,12 @@ public async Task PreservesSystemMessagesAsync() new(ChatRole.User, "Turn 2"), new(ChatRole.Assistant, "Reply 2"), ]; - DefaultChatHistoryMetricsCalculator calculator = new(); + SlidingWindowCompactionStrategy strategy = new(maxTurns: 1); - CompactionResult result = await strategy.CompactAsync(messages, calculator); + // Act & Assert + await RunCompactionStrategyReducedAsync(strategy, messages, expectedCount: 3); - Assert.True(result.Applied); - Assert.Equal(3, messages.Count); + // Assert Assert.Equal(ChatRole.System, messages[0].Role); Assert.Equal("You are a helper", messages[0].Text); Assert.Equal("Turn 2", messages[1].Text); @@ -96,7 +76,7 @@ public async Task PreservesSystemMessagesAsync() [Fact] public async Task PreservesToolGroupsWithinKeptTurnsAsync() { - SlidingWindowCompactionStrategy strategy = new(maxTurns: 1); + // Arrange List messages = [ new(ChatRole.User, "Turn 1"), @@ -106,37 +86,34 @@ public async Task PreservesToolGroupsWithinKeptTurnsAsync() new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c1", "Sunny")]), new(ChatRole.Assistant, "It's sunny!"), ]; - DefaultChatHistoryMetricsCalculator calculator = new(); + SlidingWindowCompactionStrategy strategy = new(maxTurns: 1); - CompactionResult result = await strategy.CompactAsync(messages, calculator); + // Act & Assert + await RunCompactionStrategyReducedAsync(strategy, messages, expectedCount: 4); - Assert.True(result.Applied); - // Turn 1 dropped, Turn 2 kept (user + assistant-tool-group + plain assistant) - Assert.Equal(4, messages.Count); + // Assert Assert.Equal("Get weather", messages[0].Text); } [Fact] public async Task SingleTurn_AtLimit_NoChangeAsync() { - SlidingWindowCompactionStrategy strategy = new(maxTurns: 1); + // Arrange List messages = [ new(ChatRole.User, "Hello"), new(ChatRole.Assistant, "Hi"), ]; - DefaultChatHistoryMetricsCalculator calculator = new(); - - CompactionResult result = await strategy.CompactAsync(messages, calculator); + SlidingWindowCompactionStrategy strategy = new(maxTurns: 1); - Assert.False(result.Applied); - Assert.Equal(2, messages.Count); + // Act & Assert + await RunCompactionStrategySkippedAsync(strategy, messages); } [Fact] public async Task DropsResponseGroupsFromOldTurnsAsync() { - SlidingWindowCompactionStrategy strategy = new(maxTurns: 1); + // Arrange List messages = [ new(ChatRole.User, "Turn 1"), @@ -146,12 +123,12 @@ public async Task DropsResponseGroupsFromOldTurnsAsync() new(ChatRole.User, "Turn 2"), new(ChatRole.Assistant, "Reply 2"), ]; - DefaultChatHistoryMetricsCalculator calculator = new(); + SlidingWindowCompactionStrategy strategy = new(maxTurns: 1); - CompactionResult result = await strategy.CompactAsync(messages, calculator); + // Act & Assert + await RunCompactionStrategyReducedAsync(strategy, messages, expectedCount: 2); - Assert.True(result.Applied); - Assert.Equal(2, messages.Count); + // Assert Assert.Equal("Turn 2", messages[0].Text); Assert.Equal("Reply 2", messages[1].Text); } diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/SummarizationCompactionStrategyTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/SummarizationCompactionStrategyTests.cs index 02782ad754..018d35ce48 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/SummarizationCompactionStrategyTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/SummarizationCompactionStrategyTests.cs @@ -9,44 +9,24 @@ namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction; -public class SummarizationCompactionStrategyTests +public class SummarizationCompactionStrategyTests : CompactionStrategyTestBase { - [Fact] - public void ShouldCompact_UnderLimit_ReturnsFalse() - { - Mock chatClientMock = new(); - SummarizationCompactionStrategy strategy = new(chatClientMock.Object, maxTokens: 100000); - CompactionMetric metrics = new() { TokenCount = 500 }; - - Assert.False(strategy.ShouldCompact(metrics)); - } - - [Fact] - public void ShouldCompact_OverLimit_ReturnsTrue() - { - Mock chatClientMock = new(); - SummarizationCompactionStrategy strategy = new(chatClientMock.Object, maxTokens: 100); - CompactionMetric metrics = new() { TokenCount = 500 }; - - Assert.True(strategy.ShouldCompact(metrics)); - } - [Fact] public async Task UnderLimit_NoChangeAsync() { - Mock chatClientMock = new(); - SummarizationCompactionStrategy strategy = new(chatClientMock.Object, maxTokens: 100000); + // Arrange List messages = [ new(ChatRole.User, "Hello"), new(ChatRole.Assistant, "Hi"), ]; - DefaultChatHistoryMetricsCalculator calculator = new(); + Mock chatClientMock = new(); + SummarizationCompactionStrategy strategy = new(chatClientMock.Object, maxTokens: 100000); - CompactionResult result = await strategy.CompactAsync(messages, calculator); + // Act & Assert + await RunCompactionStrategySkippedAsync(strategy, messages); - Assert.False(result.Applied); - Assert.Equal(2, messages.Count); + // Assert chatClientMock.Verify( c => c.GetResponseAsync(It.IsAny>(), It.IsAny(), It.IsAny()), Times.Never); @@ -55,13 +35,7 @@ public async Task UnderLimit_NoChangeAsync() [Fact] public async Task SummarizesOldGroupsAsync() { - Mock chatClientMock = new(); - chatClientMock - .Setup(c => c.GetResponseAsync(It.IsAny>(), It.IsAny(), It.IsAny())) - .ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "User asked about weather. It was sunny."))); - - // preserveRecentGroups=2 means keep last 2 non-system groups - SummarizationCompactionStrategy strategy = new(chatClientMock.Object, maxTokens: 1, preserveRecentGroups: 2); + // Arrange List messages = [ new(ChatRole.User, "What's the weather?"), @@ -71,14 +45,16 @@ public async Task SummarizesOldGroupsAsync() new(ChatRole.User, "Thanks!"), new(ChatRole.Assistant, "You're welcome!"), ]; - DefaultChatHistoryMetricsCalculator calculator = new(); + Mock chatClientMock = new(); + chatClientMock + .Setup(c => c.GetResponseAsync(It.IsAny>(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "User asked about weather. It was sunny."))); + SummarizationCompactionStrategy strategy = new(chatClientMock.Object, maxTokens: 1, preserveRecentGroups: 2); - CompactionResult result = await strategy.CompactAsync(messages, calculator); + // Act & Assert + await RunCompactionStrategyReducedAsync(strategy, messages, expectedCount: 3); - Assert.True(result.Applied); - // 6 groups (3 user + 3 assistant), protect last 2 → summarize first 4 groups - // Result: summary + 2 protected groups = 3 messages - Assert.Equal(3, messages.Count); + // Assert Assert.Contains("[Summary]", messages[0].Text); Assert.Contains("sunny", messages[0].Text); Assert.Equal("Thanks!", messages[1].Text); @@ -88,12 +64,7 @@ public async Task SummarizesOldGroupsAsync() [Fact] public async Task PreservesSystemMessagesAsync() { - Mock chatClientMock = new(); - chatClientMock - .Setup(c => c.GetResponseAsync(It.IsAny>(), It.IsAny(), It.IsAny())) - .ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "Summary of earlier discussion."))); - - SummarizationCompactionStrategy strategy = new(chatClientMock.Object, maxTokens: 1, preserveRecentGroups: 1); + // Arrange List messages = [ new(ChatRole.System, "You are a helper"), @@ -102,11 +73,16 @@ public async Task PreservesSystemMessagesAsync() new(ChatRole.User, "Turn 2"), new(ChatRole.Assistant, "Reply 2"), ]; - DefaultChatHistoryMetricsCalculator calculator = new(); + Mock chatClientMock = new(); + chatClientMock + .Setup(c => c.GetResponseAsync(It.IsAny>(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "Summary of earlier discussion."))); + SummarizationCompactionStrategy strategy = new(chatClientMock.Object, maxTokens: 1, preserveRecentGroups: 1); - CompactionResult result = await strategy.CompactAsync(messages, calculator); + // Act & Assert + await RunCompactionStrategyReducedAsync(strategy, messages, expectedCount: 3); - Assert.True(result.Applied); + // Assert Assert.Equal(ChatRole.System, messages[0].Role); Assert.Equal("You are a helper", messages[0].Text); Assert.Contains("[Summary]", messages[1].Text); @@ -115,20 +91,19 @@ public async Task PreservesSystemMessagesAsync() [Fact] public async Task AllGroupsProtected_NoChangeAsync() { - Mock chatClientMock = new(); - SummarizationCompactionStrategy strategy = new(chatClientMock.Object, maxTokens: 1, preserveRecentGroups: 10); + // Arrange List messages = [ new(ChatRole.User, "Hello"), new(ChatRole.Assistant, "Hi"), ]; - DefaultChatHistoryMetricsCalculator calculator = new(); + Mock chatClientMock = new(); + SummarizationCompactionStrategy strategy = new(chatClientMock.Object, maxTokens: 1, preserveRecentGroups: 10); - CompactionResult result = await strategy.CompactAsync(messages, calculator); + // Act & Assert + await RunCompactionStrategySkippedAsync(strategy, messages); - // All groups protected → nothing to summarize → no change - Assert.False(result.Applied); - Assert.Equal(2, messages.Count); + // Assert chatClientMock.Verify( c => c.GetResponseAsync(It.IsAny>(), It.IsAny(), It.IsAny()), Times.Never); @@ -137,16 +112,8 @@ public async Task AllGroupsProtected_NoChangeAsync() [Fact] public async Task CustomPrompt_UsedInRequestAsync() { + // Arrange const string CustomPrompt = "Summarize briefly."; - List? capturedMessages = null; - - Mock chatClientMock = new(); - chatClientMock - .Setup(c => c.GetResponseAsync(It.IsAny>(), It.IsAny(), It.IsAny())) - .Callback, ChatOptions, CancellationToken>((msgs, _, _) => capturedMessages = [.. msgs]) - .ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "Brief summary."))); - - SummarizationCompactionStrategy strategy = new(chatClientMock.Object, maxTokens: 1, preserveRecentGroups: 1, summarizationPrompt: CustomPrompt); List messages = [ new(ChatRole.User, "First"), @@ -154,12 +121,19 @@ public async Task CustomPrompt_UsedInRequestAsync() new(ChatRole.User, "Second"), new(ChatRole.Assistant, "Reply 2"), ]; - DefaultChatHistoryMetricsCalculator calculator = new(); + List? capturedMessages = null; + Mock chatClientMock = new(); + chatClientMock + .Setup(c => c.GetResponseAsync(It.IsAny>(), It.IsAny(), It.IsAny())) + .Callback, ChatOptions, CancellationToken>((msgs, _, _) => capturedMessages = [.. msgs]) + .ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "Brief summary."))); + SummarizationCompactionStrategy strategy = new(chatClientMock.Object, maxTokens: 1, preserveRecentGroups: 1, summarizationPrompt: CustomPrompt); - await strategy.CompactAsync(messages, calculator); + // Act & Assert + await RunCompactionStrategyReducedAsync(strategy, messages, expectedCount: 2); + // Assert Assert.NotNull(capturedMessages); - // First message in request should be the custom system prompt Assert.Equal(ChatRole.System, capturedMessages![0].Role); Assert.Equal(CustomPrompt, capturedMessages[0].Text); } @@ -167,12 +141,7 @@ public async Task CustomPrompt_UsedInRequestAsync() [Fact] public async Task NullResponseText_UsesFallbackAsync() { - Mock chatClientMock = new(); - chatClientMock - .Setup(c => c.GetResponseAsync(It.IsAny>(), It.IsAny(), It.IsAny())) - .ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, (string?)null))); - - SummarizationCompactionStrategy strategy = new(chatClientMock.Object, maxTokens: 1, preserveRecentGroups: 1); + // Arrange List messages = [ new(ChatRole.User, "First"), @@ -180,11 +149,16 @@ public async Task NullResponseText_UsesFallbackAsync() new(ChatRole.User, "Second"), new(ChatRole.Assistant, "Reply 2"), ]; - DefaultChatHistoryMetricsCalculator calculator = new(); + Mock chatClientMock = new(); + chatClientMock + .Setup(c => c.GetResponseAsync(It.IsAny>(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, (string?)null))); + SummarizationCompactionStrategy strategy = new(chatClientMock.Object, maxTokens: 1, preserveRecentGroups: 1); - CompactionResult result = await strategy.CompactAsync(messages, calculator); + // Act & Assert + await RunCompactionStrategyReducedAsync(strategy, messages, expectedCount: 2); - Assert.True(result.Applied); + // Assert Assert.Contains("[Summary unavailable]", messages[0].Text); } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/ToolResultCompactionStrategyTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/ToolResultCompactionStrategyTests.cs index 8a6065a940..d2b3113ec6 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/ToolResultCompactionStrategyTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/ToolResultCompactionStrategyTests.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System.Collections.Generic; using System.Threading.Tasks; @@ -7,57 +7,28 @@ namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction; -public class ToolResultCompactionStrategyTests +public class ToolResultCompactionStrategyTests : CompactionStrategyTestBase { - [Fact] - public void ShouldCompact_UnderLimit_ReturnsFalse() - { - ToolResultCompactionStrategy strategy = new(maxTokens: 100000); - CompactionMetric metrics = new() { TokenCount = 500, ToolCallCount = 3 }; - - Assert.False(strategy.ShouldCompact(metrics)); - } - - [Fact] - public void ShouldCompact_OverLimitNoToolCalls_ReturnsFalse() - { - ToolResultCompactionStrategy strategy = new(maxTokens: 100); - CompactionMetric metrics = new() { TokenCount = 500, ToolCallCount = 0 }; - - Assert.False(strategy.ShouldCompact(metrics)); - } - - [Fact] - public void ShouldCompact_OverLimitWithToolCalls_ReturnsTrue() - { - ToolResultCompactionStrategy strategy = new(maxTokens: 100); - CompactionMetric metrics = new() { TokenCount = 500, ToolCallCount = 2 }; - - Assert.True(strategy.ShouldCompact(metrics)); - } - [Fact] public async Task UnderLimit_NoChangeAsync() { - ToolResultCompactionStrategy strategy = new(maxTokens: 100000); + // Arrange List messages = [ new(ChatRole.User, "Hello"), new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "get_weather")]), new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c1", "Sunny")]), ]; - DefaultChatHistoryMetricsCalculator calculator = new(); - - CompactionResult result = await strategy.CompactAsync(messages, calculator); + ToolResultCompactionStrategy strategy = new(maxTokens: 100000); - Assert.False(result.Applied); - Assert.Equal(3, messages.Count); + // Act & Assert + await RunCompactionStrategySkippedAsync(strategy, messages); } [Fact] public async Task CollapsesOldToolGroupAsync() { - ToolResultCompactionStrategy strategy = new(maxTokens: 1, preserveRecentGroups: 1); + // Arrange List messages = [ new(ChatRole.User, "Check weather"), @@ -66,13 +37,12 @@ public async Task CollapsesOldToolGroupAsync() new(ChatRole.User, "Thanks"), new(ChatRole.Assistant, "You're welcome!"), ]; - DefaultChatHistoryMetricsCalculator calculator = new(); + ToolResultCompactionStrategy strategy = new(maxTokens: 1, preserveRecentGroups: 1); - CompactionResult result = await strategy.CompactAsync(messages, calculator); + // Act & Assert + await RunCompactionStrategyReducedAsync(strategy, messages, expectedCount: 4); - Assert.True(result.Applied); - // The old tool group (assistant+tool = 2 messages) should be collapsed to 1 - Assert.Equal(4, messages.Count); // user + [collapsed] + user + assistant + // Assert Assert.Contains("[Tool calls: get_weather]", messages[1].Text); Assert.Equal(ChatRole.Assistant, messages[1].Role); } @@ -80,8 +50,7 @@ public async Task CollapsesOldToolGroupAsync() [Fact] public async Task ProtectsRecentGroupsAsync() { - // With preserveRecentGroups=4, all groups are protected (5 groups, protect 4 non-system) - ToolResultCompactionStrategy strategy = new(maxTokens: 1, preserveRecentGroups: 10); + // Arrange List messages = [ new(ChatRole.User, "Check weather"), @@ -90,19 +59,16 @@ public async Task ProtectsRecentGroupsAsync() new(ChatRole.User, "Thanks"), new(ChatRole.Assistant, "You're welcome!"), ]; - DefaultChatHistoryMetricsCalculator calculator = new(); - - CompactionResult result = await strategy.CompactAsync(messages, calculator); + ToolResultCompactionStrategy strategy = new(maxTokens: 1, preserveRecentGroups: 10); - // All groups protected, so no collapse - Assert.False(result.Applied); - Assert.Equal(5, messages.Count); + // Act & Assert + await RunCompactionStrategySkippedAsync(strategy, messages); } [Fact] public async Task PreservesSystemMessagesAsync() { - ToolResultCompactionStrategy strategy = new(maxTokens: 1, preserveRecentGroups: 1); + // Arrange List messages = [ new(ChatRole.System, "You are a helper"), @@ -112,11 +78,12 @@ public async Task PreservesSystemMessagesAsync() new(ChatRole.User, "Thanks"), new(ChatRole.Assistant, "You're welcome!"), ]; - DefaultChatHistoryMetricsCalculator calculator = new(); + ToolResultCompactionStrategy strategy = new(maxTokens: 1, preserveRecentGroups: 1); - CompactionResult result = await strategy.CompactAsync(messages, calculator); + // Act & Assert + await RunCompactionStrategyReducedAsync(strategy, messages, expectedCount: 5); - Assert.True(result.Applied); + // Assert Assert.Equal(ChatRole.System, messages[0].Role); Assert.Equal("You are a helper", messages[0].Text); } @@ -124,7 +91,7 @@ public async Task PreservesSystemMessagesAsync() [Fact] public async Task MultipleToolCalls_ListedInSummaryAsync() { - ToolResultCompactionStrategy strategy = new(maxTokens: 1, preserveRecentGroups: 1); + // Arrange List messages = [ new(ChatRole.User, "Do research"), @@ -137,13 +104,12 @@ public async Task MultipleToolCalls_ListedInSummaryAsync() new(ChatRole.User, "Summarize"), new(ChatRole.Assistant, "Here's the summary."), ]; - DefaultChatHistoryMetricsCalculator calculator = new(); + ToolResultCompactionStrategy strategy = new(maxTokens: 1, preserveRecentGroups: 1); - CompactionResult result = await strategy.CompactAsync(messages, calculator); + // Act & Assert + await RunCompactionStrategyReducedAsync(strategy, messages, expectedCount: 4); - Assert.True(result.Applied); - // Old tool group (1 assistant + 2 tools = 3 messages) collapsed to 1 - Assert.Equal(4, messages.Count); + // Assert Assert.Contains("search", messages[1].Text); Assert.Contains("fetch_page", messages[1].Text); } @@ -151,18 +117,15 @@ public async Task MultipleToolCalls_ListedInSummaryAsync() [Fact] public async Task NoToolGroups_NoChangeAsync() { - ToolResultCompactionStrategy strategy = new(maxTokens: 1); + // Arrange List messages = [ new(ChatRole.User, "Hello"), new(ChatRole.Assistant, "Hi there"), ]; - DefaultChatHistoryMetricsCalculator calculator = new(); - - // ToolCallCount is 0, so ShouldCompact returns false - CompactionResult result = await strategy.CompactAsync(messages, calculator); + ToolResultCompactionStrategy strategy = new(maxTokens: 1); - Assert.False(result.Applied); - Assert.Equal(2, messages.Count); + // Act & Assert + await RunCompactionStrategySkippedAsync(strategy, messages); } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/TruncationCompactionStrategyTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/TruncationCompactionStrategyTests.cs index 47929d23e5..b4be4422a5 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/TruncationCompactionStrategyTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/TruncationCompactionStrategyTests.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System.Collections.Generic; using System.Threading.Tasks; @@ -7,29 +7,26 @@ namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction; -public class TruncationCompactionStrategyTests +public class TruncationCompactionStrategyTests : CompactionStrategyTestBase { [Fact] public async Task UnderLimit_NoChangeAsync() { - TruncationCompactionStrategy strategy = new(maxTokens: 100000); + // Arrange List messages = [ new(ChatRole.User, "Hello"), ]; - DefaultChatHistoryMetricsCalculator calculator = new(); - - CompactionResult result = await strategy.CompactAsync(messages, calculator); + TruncationCompactionStrategy strategy = new(maxTokens: 100000); - Assert.False(result.Applied); - Assert.Single(messages); + // Act & Assert + await RunCompactionStrategySkippedAsync(strategy, messages); } [Fact] public async Task OverLimit_RemovesOldestGroupsAsync() { - // Use a very low max to trigger compaction - TruncationCompactionStrategy strategy = new(maxTokens: 1); + // Arrange List messages = [ new(ChatRole.User, "First message"), @@ -37,77 +34,45 @@ public async Task OverLimit_RemovesOldestGroupsAsync() new(ChatRole.User, "Second message"), new(ChatRole.Assistant, "Second reply"), ]; - DefaultChatHistoryMetricsCalculator calculator = new(); - - CompactionResult result = await strategy.CompactAsync(messages, calculator); - - Assert.True(result.Applied); - // Should have removed old groups, keeping at least the last one - Assert.True(messages.Count < 4); - Assert.True(messages.Count > 0); - } - - [Fact] - public void ShouldCompact_ReturnsFalseWhenUnderLimit() - { - TruncationCompactionStrategy strategy = new(maxTokens: 10000); - CompactionMetric metrics = new() { TokenCount = 500 }; - - Assert.False(strategy.ShouldCompact(metrics)); - } - - [Fact] - public void ShouldCompact_ReturnsTrueWhenOverLimit() - { - TruncationCompactionStrategy strategy = new(maxTokens: 100); - CompactionMetric metrics = new() { TokenCount = 500 }; + TruncationCompactionStrategy strategy = new(maxTokens: 1); - Assert.True(strategy.ShouldCompact(metrics)); + // Act & Assert + await RunCompactionStrategyReducedAsync(strategy, messages, expectedCount: 1); } [Fact] public async Task SystemOnlyMessages_NoChangeAsync() { - // Only system messages → removableGroups.Count == 0 → no change - TruncationCompactionStrategy strategy = new(maxTokens: 1); + // Arrange List messages = [ new(ChatRole.System, "You are a helper"), ]; - DefaultChatHistoryMetricsCalculator calculator = new(); - - CompactionResult result = await strategy.CompactAsync(messages, calculator); + TruncationCompactionStrategy strategy = new(maxTokens: 1); - // ShouldCompact triggers but reducer finds nothing removable - Assert.Single(messages); - Assert.Equal(result.After.MessageCount, result.Before.MessageCount); - Assert.Equal(result.After.TokenCount, result.Before.TokenCount); + // Act & Assert + await RunCompactionStrategySkippedAsync(strategy, messages); } [Fact] public async Task SingleNonSystemGroup_NoChangeAsync() { - // Only one non-system group → maxRemovable <= 0 → no change - TruncationCompactionStrategy strategy = new(maxTokens: 1); + // Arrange List messages = [ new(ChatRole.System, "System prompt"), new(ChatRole.User, "Only user message"), ]; - DefaultChatHistoryMetricsCalculator calculator = new(); - - CompactionResult result = await strategy.CompactAsync(messages, calculator); + TruncationCompactionStrategy strategy = new(maxTokens: 1); - Assert.Equal(2, messages.Count); - Assert.Equal(result.After.MessageCount, result.Before.MessageCount); - Assert.Equal(result.After.TokenCount, result.Before.TokenCount); + // Act & Assert + await RunCompactionStrategySkippedAsync(strategy, messages); } [Fact] public async Task PreserveRecentGroups_KeepsMultipleGroupsAsync() { - // preserveRecentGroups=2 means keep at least the last 2 non-system groups - TruncationCompactionStrategy strategy = new(maxTokens: 1, preserveRecentGroups: 2); + // Arrange List messages = [ new(ChatRole.User, "Turn 1"), @@ -117,14 +82,12 @@ public async Task PreserveRecentGroups_KeepsMultipleGroupsAsync() new(ChatRole.User, "Turn 3"), new(ChatRole.Assistant, "Reply 3"), ]; - DefaultChatHistoryMetricsCalculator calculator = new(); + TruncationCompactionStrategy strategy = new(maxTokens: 1, preserveRecentGroups: 2); - CompactionResult result = await strategy.CompactAsync(messages, calculator); + // Act & Assert + await RunCompactionStrategyReducedAsync(strategy, messages, expectedCount: 2); - Assert.True(result.Applied); - // 6 groups (3 user + 3 assistant), protect last 2 → 4 removable - // Should keep at least: Turn 3 user + Reply 3 assistant (last 2 groups) - Assert.True(messages.Count >= 2); + // Assert Assert.Equal("Reply 3", messages[^1].Text); } } From 475c0656800ec3f18938aed9c12ac199de9602d8 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Thu, 5 Mar 2026 01:53:53 -0800 Subject: [PATCH 4/6] Sample --- .../Program.cs | 30 ++++++++++--------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/Program.cs b/dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/Program.cs index 9a3e935e01..5aa5a76e44 100644 --- a/dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/Program.cs +++ b/dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/Program.cs @@ -40,25 +40,27 @@ static string LookupPrice([Description("The product name to look up.")] string p }; // Configure the compaction pipeline with one of each strategy, ordered least to most aggressive. -//const int MaxTokens = 512; -//const int MaxTurns = 4; +const int MaxTokens = 512; +const int MaxTurns = 4; ChatHistoryCompactionPipeline compactionPipeline = - //new(// 1. Gentle: collapse old tool-call groups into short summaries like "[Tool calls: LookupPrice]" - // new ToolResultCompactionStrategy(MaxTokens, preserveRecentGroups: 2), + new(// 1. Gentle: collapse old tool-call groups into short summaries like "[Tool calls: LookupPrice]" + new ToolResultCompactionStrategy(MaxTokens, preserveRecentGroups: 2), - // // 2. Moderate: use an LLM to summarize older conversation spans into a concise message - // new SummarizationCompactionStrategy(summarizerChatClient, MaxTokens, preserveRecentGroups: 2), + // 2. Moderate: use an LLM to summarize older conversation spans into a concise message + new SummarizationCompactionStrategy(summarizerChatClient, MaxTokens, preserveRecentGroups: 2), - // // 3. Aggressive: keep only the last N user turns and their responses - // new SlidingWindowCompactionStrategy(MaxTurns), + // 3. Aggressive: keep only the last N user turns and their responses + new SlidingWindowCompactionStrategy(MaxTurns), - // // 4. Emergency: drop oldest groups until under the token budget - // new TruncationCompactionStrategy(MaxTokens, preserveRecentGroups: 1)); - Create( - Approach.Balanced, - Size.Compact, - summarizerChatClient); + // 4. Emergency: drop oldest groups until under the token budget + new TruncationCompactionStrategy(MaxTokens, preserveRecentGroups: 1)); + +// TODO: PRECONFIGURED PIPELINE +////Create( +//// Approach.Balanced, +//// Size.Compact, +//// summarizerChatClient); // Create the agent with an in-memory chat history provider whose reducer is the compaction pipeline. AIAgent agent = From c1b1c18bc257ffaa2dcc175c4a48c582df283035 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Thu, 5 Mar 2026 02:29:36 -0800 Subject: [PATCH 5/6] Namespace --- .../02-agents/Agents/Agent_Step18_CompactionPipeline/Program.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/Program.cs b/dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/Program.cs index 5aa5a76e44..d51fc75621 100644 --- a/dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/Program.cs +++ b/dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/Program.cs @@ -13,7 +13,6 @@ using Microsoft.Agents.AI; using Microsoft.Agents.AI.Compaction; using Microsoft.Extensions.AI; -using static Microsoft.Agents.AI.Compaction.ChatHistoryCompactionPipeline; var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; From e296bb5078fa7ab91d1e84e5019fd8456ba125cf Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Thu, 5 Mar 2026 08:35:49 -0800 Subject: [PATCH 6/6] Sample readme --- dotnet/samples/02-agents/Agents/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/dotnet/samples/02-agents/Agents/README.md b/dotnet/samples/02-agents/Agents/README.md index 116cbfc06b..4ac53ba246 100644 --- a/dotnet/samples/02-agents/Agents/README.md +++ b/dotnet/samples/02-agents/Agents/README.md @@ -44,6 +44,7 @@ Before you begin, ensure you have the following prerequisites: |[Deep research with an agent](./Agent_Step15_DeepResearch/)|This sample demonstrates how to use the Deep Research Tool to perform comprehensive research on complex topics| |[Declarative agent](./Agent_Step16_Declarative/)|This sample demonstrates how to declaratively define an agent.| |[Providing additional AI Context to an agent using multiple AIContextProviders](./Agent_Step17_AdditionalAIContext/)|This sample demonstrates how to inject additional AI context into a ChatClientAgent using multiple custom AIContextProvider components that are attached to the agent.| +|[Using compaction pipeline with an agent](./Agent_Step18_CompactionPipeline/)|This sample demonstrates how to use a compaction pipeline to efficiently limit the size of the conversation history for an agent.| ## Running the samples from the console