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); + } +}