Checkpoint

This commit is contained in:
Chris Rickman
2026-03-02 13:27:25 -08:00
Unverified
parent b295a16c0e
commit e061d095e7
29 changed files with 2734 additions and 0 deletions
@@ -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;
/// <summary>
/// Executes a chain of <see cref="ChatHistoryCompactionStrategy"/> instances in order
/// against a mutable message list.
/// </summary>
/// <remarks>
/// <para>
/// Each strategy's trigger is evaluated against the metrics <em>as they stand after prior strategies</em>,
/// so earlier strategies can bring the conversation within thresholds that cause later strategies to skip.
/// </para>
/// <para>
/// The pipeline is fully standalone — it can be used without any agent, session, or context provider.
/// It also implements <see cref="IChatReducer"/> so it can be used directly anywhere a reducer is
/// accepted (e.g., <see cref="InMemoryChatHistoryProviderOptions.ChatReducer"/>).
/// </para>
/// </remarks>
public class ChatHistoryCompactionPipeline : IChatReducer
{
private readonly ChatHistoryCompactionStrategy[] _strategies;
private readonly IChatHistoryMetricsCalculator _metricsCalculator;
/// <summary>
/// Initializes a new instance of the <see cref="ChatHistoryCompactionPipeline"/> class.
/// </summary>
/// <param name="strategies">The ordered list of compaction strategies to execute.</param>
/// <remarks>
/// By default, <see cref="DefaultChatHistoryMetricsCalculator"/> is used.
/// </remarks>
public ChatHistoryCompactionPipeline(
IEnumerable<ChatHistoryCompactionStrategy> strategies)
: this(metricsCalculator: null, strategies) { }
/// <summary>
/// Initializes a new instance of the <see cref="ChatHistoryCompactionPipeline"/> class.
/// </summary>
/// <param name="metricsCalculator">
/// An optional metrics calculator. When <see langword="null"/>, a
/// <see cref="DefaultChatHistoryMetricsCalculator"/> is used.
/// </param>
/// <param name="strategies">The ordered list of compaction strategies to execute.</param>
public ChatHistoryCompactionPipeline(
IChatHistoryMetricsCalculator? metricsCalculator,
params IEnumerable<ChatHistoryCompactionStrategy> strategies)
{
this._strategies = Throw.IfNull(strategies).ToArray();
this._metricsCalculator = metricsCalculator ?? DefaultChatHistoryMetricsCalculator.Instance;
}
/// <summary>
/// Reduces the given messages by running all strategies in sequence.
/// </summary>
/// <param name="messages">The messages to reduce.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
/// <returns>The reduced set of messages.</returns>
public virtual async Task<IEnumerable<ChatMessage>> ReduceAsync(
IEnumerable<ChatMessage> messages,
CancellationToken cancellationToken = default)
{
List<ChatMessage> messageList = messages.ToList(); // %%% HAXX
await this.CompactAsync(messageList, cancellationToken).ConfigureAwait(false);
return messageList;
}
/// <summary>
/// Run all strategies in sequence against the given messages.
/// </summary>
/// <param name="messages">The mutable message list to compact.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
/// <returns>A <see cref="CompactionPipelineResult"/> with aggregate and per-strategy metrics.</returns>
public async ValueTask<CompactionPipelineResult> CompactAsync( // %%% SCOPE
IList<ChatMessage> messages,
CancellationToken cancellationToken = default)
{
Throw.IfNull(messages);
IReadOnlyList<ChatMessage> readOnlyMessages = messages as IReadOnlyList<ChatMessage> ?? [.. messages]; // %%% TYPE CONSISTENCY
CompactionMetric overallBefore = this._metricsCalculator.Calculate(readOnlyMessages);
List<CompactionResult> 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<ChatMessage> ?? [.. messages];
CompactionMetric overallAfter = this._metricsCalculator.Calculate(readOnlyMessages);
return new(overallBefore, overallAfter, results);
}
}
@@ -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;
/// <summary>
/// A named compaction strategy with an optional conditional trigger that delegates
/// actual message reduction to an <see cref="IChatReducer"/>.
/// </summary>
/// <remarks>
/// <para>
/// Each strategy wraps an <see cref="IChatReducer"/> that performs the actual compaction,
/// while the strategy adds:
/// <list type="bullet">
/// <item><description>A conditional trigger via <see cref="ShouldCompact"/> that decides whether compaction runs.</description></item>
/// <item><description>Before/after <see cref="CompactionMetric"/> reporting via <see cref="CompactionResult"/>.</description></item>
/// </list>
/// </para>
/// <para>
/// For simple cases, construct a <see cref="ChatHistoryCompactionStrategy"/> directly with any
/// <see cref="IChatReducer"/>. For custom trigger logic, subclass and override <see cref="ShouldCompact"/>.
/// </para>
/// <para>
/// Reducers <b>must</b> preserve atomic message groups: an assistant message containing
/// tool calls and its corresponding tool result messages must be kept or removed together.
/// Use <see cref="DefaultChatHistoryMetricsCalculator"/> to identify these groups when authoring custom reducers.
/// </para>
/// </remarks>
public abstract class ChatHistoryCompactionStrategy
{
private static readonly AsyncLocal<CompactionMetric> s_currentMetrics = new();
/// <summary>
/// Initializes a new instance of the <see cref="ChatHistoryCompactionStrategy"/> class.
/// </summary>
/// <param name="reducer">The <see cref="IChatReducer"/> that performs the actual message compaction.</param>
protected ChatHistoryCompactionStrategy(IChatReducer reducer)
{
this.Reducer = Throw.IfNull(reducer);
}
/// <summary>
/// Exposes the current <see cref="CompactionMetric"/> for the executing strategy, allowing <see cref="Reducer"/> to make informed decisions.
/// </summary>
protected static CompactionMetric CurrentMetrics => s_currentMetrics.Value ?? throw new InvalidOperationException($"No active {nameof(ChatHistoryCompactionStrategy)}.");
/// <summary>
/// Gets the <see cref="IChatReducer"/> that performs the actual message compaction.
/// </summary>
public IChatReducer Reducer { get; }
/// <summary>
/// Gets the display name of this strategy, used for logging and diagnostics.
/// </summary>
/// <remarks>
/// The default implementation returns the type name of the underlying <see cref="IChatReducer"/>.
/// </remarks>
public virtual string Name => this.Reducer.GetType().Name;
/// <summary>
/// Evaluates whether this strategy should execute given the current conversation metrics.
/// </summary>
/// <param name="metrics">The current conversation metrics.</param>
/// <returns>
/// <see langword="true"/> to proceed with compaction; <see langword="false"/> to skip.
/// </returns>
public abstract bool ShouldCompact(CompactionMetric metrics);
/// <summary>
/// Execute this strategy: check the trigger, delegate to the <see cref="IChatReducer"/>, and report metrics.
/// </summary>
/// <param name="messages">The mutable message list to compact.</param>
/// <param name="metricsCalculator">The calculator to use for metric snapshots.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
/// <returns>A <see cref="CompactionResult"/> reporting the outcome.</returns>
public async ValueTask<CompactionResult> CompactAsync(
IList<ChatMessage> messages,
IChatHistoryMetricsCalculator metricsCalculator,
CancellationToken cancellationToken = default)
{
messages = Throw.IfNull(messages);
Throw.IfNull(metricsCalculator);
List<ChatMessage>? messageList = messages as List<ChatMessage>;
ReadOnlyCollection<ChatMessage> 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);
}
}
@@ -0,0 +1,64 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
namespace Microsoft.Agents.AI.Compaction;
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public readonly struct ChatMessageGroup : IEquatable<ChatMessageGroup>
{
/// <summary>
/// Initializes a new instance of the <see cref="ChatMessageGroup"/> struct.
/// </summary>
/// <param name="startIndex">The zero-based index of the first message in this group.</param>
/// <param name="count">The number of messages in this group.</param>
/// <param name="kind">The kind of this message group.</param>
public ChatMessageGroup(int startIndex, int count, ChatMessageGroupKind kind)
{
this.StartIndex = startIndex;
this.Count = count;
this.Kind = kind;
}
/// <summary>
/// Gets the zero-based index of the first message in this group within the original message list.
/// </summary>
public int StartIndex { get; }
/// <summary>
/// Gets the number of messages in this group.
/// </summary>
public int Count { get; }
/// <summary>
/// Gets the kind of this message group.
/// </summary>
public ChatMessageGroupKind Kind { get; }
/// <inheritdoc/>
public bool Equals(ChatMessageGroup other) =>
this.StartIndex == other.StartIndex &&
this.Count == other.Count &&
this.Kind == other.Kind;
/// <inheritdoc/>
public override bool Equals(object? obj) =>
obj is ChatMessageGroup other &&
this.Equals(other);
/// <inheritdoc/>
public override int GetHashCode() => HashCode.Combine(this.StartIndex, this.Count, (int)this.Kind);
/// <summary>Determines whether two <see cref="ChatMessageGroup"/> instances are equal.</summary>
public static bool operator ==(ChatMessageGroup left, ChatMessageGroup right) => left.Equals(right);
/// <summary>Determines whether two <see cref="ChatMessageGroup"/> instances are not equal.</summary>
public static bool operator !=(ChatMessageGroup left, ChatMessageGroup right) => !left.Equals(right);
}
@@ -0,0 +1,27 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI.Compaction;
/// <summary>
/// Identifies the kind of an atomic message group in a conversation.
/// </summary>
public enum ChatMessageGroupKind
{
/// <summary>A system message.</summary>
System,
/// <summary>A user message (start of a user turn).</summary>
UserTurn,
/// <summary>An assistant message with tool calls and their corresponding tool result messages.</summary>
AssistantToolGroup,
/// <summary>An assistant message without tool calls.</summary>
AssistantPlain,
/// <summary>A tool result message that is not part of a recognized group.</summary>
ToolResult,
/// <summary>A message with an unrecognized role.</summary>
Other
}
@@ -0,0 +1,45 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
namespace Microsoft.Agents.AI.Compaction;
/// <summary>
/// Immutable snapshot of conversation metrics used for compaction trigger evaluation and reporting.
/// </summary>
public sealed class CompactionMetric
{
/// <summary>
/// Gets the estimated token count across all messages.
/// </summary>
public int TokenCount { get; init; }
/// <summary>
/// Gets the total serialized byte count of all messages.
/// </summary>
public long ByteCount { get; init; }
#pragma warning disable IDE0001 // Simplify Names
/// <summary>
/// Gets the total number of <see cref="Microsoft.Extensions.AI.ChatMessage"/> objects.
/// </summary>
#pragma warning restore IDE0001 // Simplify Names
public int MessageCount { get; init; }
/// <summary>
/// Gets the number of tool/function call content items across all messages.
/// </summary>
public int ToolCallCount { get; init; }
/// <summary>
/// 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.
/// </summary>
public int UserTurnCount { get; init; }
/// <summary>
/// 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.
/// </summary>
public IReadOnlyList<ChatMessageGroup> Groups { get; init; } = [];
}
@@ -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;
/// <summary>
/// Reports the aggregate outcome of a <see cref="ChatHistoryCompactionPipeline"/> execution.
/// </summary>
public sealed class CompactionPipelineResult
{
/// <summary>
/// Initializes a new instance of the <see cref="CompactionPipelineResult"/> class.
/// </summary>
/// <param name="before">Metrics of the conversation before any strategy ran.</param>
/// <param name="after">Metrics of the conversation after all strategies ran.</param>
/// <param name="strategyResults">Per-strategy results in execution order.</param>
internal CompactionPipelineResult(
CompactionMetric before,
CompactionMetric after,
IReadOnlyList<CompactionResult> strategyResults)
{
this.Before = Throw.IfNull(before);
this.After = Throw.IfNull(after);
this.StrategyResults = Throw.IfNull(strategyResults);
}
/// <summary>
/// Gets the conversation metrics before any compaction strategy ran.
/// </summary>
public CompactionMetric Before { get; }
/// <summary>
/// Gets the conversation metrics after all compaction strategies ran.
/// </summary>
public CompactionMetric After { get; }
/// <summary>
/// Gets the per-strategy results in execution order.
/// </summary>
public IReadOnlyList<CompactionResult> StrategyResults { get; }
/// <summary>
/// Gets a value indicating whether any strategy modified the message list.
/// </summary>
public bool AnyApplied => this.StrategyResults.Any(r => r.Applied);
}
@@ -0,0 +1,55 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Compaction;
/// <summary>
/// Reports the outcome of a single <see cref="ChatHistoryCompactionStrategy"/> execution.
/// </summary>
public sealed class CompactionResult
{
/// <summary>
/// Initializes a new instance of the <see cref="CompactionResult"/> class.
/// </summary>
/// <param name="strategyName">The name of the strategy that produced this result.</param>
/// <param name="applied">Whether the strategy modified the message list.</param>
/// <param name="before">Metrics before the strategy ran.</param>
/// <param name="after">Metrics after the strategy ran.</param>
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);
}
/// <summary>
/// Gets the name of the strategy that produced this result.
/// </summary>
public string StrategyName { get; }
/// <summary>
/// Gets a value indicating whether the strategy modified the message list.
/// </summary>
public bool Applied { get; }
/// <summary>
/// Gets the conversation metrics before the strategy executed.
/// </summary>
public CompactionMetric Before { get; }
/// <summary>
/// Gets the conversation metrics after the strategy executed.
/// </summary>
public CompactionMetric After { get; }
/// <summary>
/// Creates a <see cref="CompactionResult"/> representing a skipped strategy.
/// </summary>
/// <param name="strategyName">The name of the skipped strategy.</param>
/// <param name="metrics">The current conversation metrics.</param>
/// <returns>A result indicating no compaction was applied.</returns>
internal static CompactionResult Skipped(string strategyName, CompactionMetric metrics)
=> new(strategyName, applied: false, metrics, metrics);
}
@@ -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;
/// <summary>
/// Default implementation of <see cref="IChatHistoryMetricsCalculator"/> that uses
/// JSON serialization length heuristics for token and byte estimation.
/// </summary>
/// <remarks>
/// <para>
/// 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 <see cref="IChatHistoryMetricsCalculator"/>
/// with a model-appropriate tokenizer.
/// </para>
/// </remarks>
public sealed class DefaultChatHistoryMetricsCalculator : IChatHistoryMetricsCalculator
{
/// <summary>
/// Gets the singleton instance of the chat history metrics calculator.
/// </summary>
/// <remarks>
/// <see cref="DefaultChatHistoryMetricsCalculator"/> can be safety accessed by
/// concurrent threads.
/// </remarks>
public static readonly DefaultChatHistoryMetricsCalculator Instance = new();
private const int DefaultCharsPerToken = 4;
private const int PerMessageOverheadTokens = 4;
private readonly int _charsPerToken;
/// <summary>
/// Initializes a new instance of the <see cref="DefaultChatHistoryMetricsCalculator"/> class.
/// </summary>
/// <param name="charsPerToken">
/// The approximate number of characters per token used for estimation. Default is 4.
/// </param>
public DefaultChatHistoryMetricsCalculator(int charsPerToken = DefaultCharsPerToken)
{
this._charsPerToken = charsPerToken > 0 ? charsPerToken : DefaultCharsPerToken;
}
/// <inheritdoc/>
public CompactionMetric Calculate(IReadOnlyList<ChatMessage> 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<ChatMessageGroup> 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++;
}
}
}
}
}
@@ -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.
/// <summary>
/// Computes <see cref="CompactionMetric"/> for a list of messages.
/// </summary>
/// <remarks>
/// Token counting is model-specific. Implementations can provide precise tokenization
/// (e.g., using tiktoken or a model-specific tokenizer) or use estimation heuristics.
/// </remarks>
public interface IChatHistoryMetricsCalculator // %%% NEEDED ???
{
/// <summary>
/// Compute metrics for the given messages.
/// </summary>
/// <param name="messages">The messages to analyze.</param>
/// <returns>A <see cref="CompactionMetric"/> snapshot.</returns>
CompactionMetric Calculate(IReadOnlyList<ChatMessage> messages);
}
@@ -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;
/// <summary>
/// A compaction strategy that keeps only the most recent user turns and their
/// associated response groups, removing older turns to bound conversation length.
/// </summary>
/// <remarks>
/// <para>
/// This strategy always preserves system messages. It identifies user turns in the
/// conversation and keeps the last <c>maxTurns</c> turns along with all response groups
/// (assistant replies, tool call groups) that follow each kept turn.
/// </para>
/// <para>
/// The trigger condition fires only when the number of user turns exceeds <c>maxTurns</c>.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
public class SlidingWindowCompactionStrategy : ChatHistoryCompactionStrategy
{
private readonly int _maxTurns;
/// <summary>
/// Initializes a new instance of the <see cref="SlidingWindowCompactionStrategy"/> class.
/// </summary>
/// <param name="maxTurns">
/// The maximum number of user turns to keep. Older turns and their associated responses are removed.
/// </param>
public SlidingWindowCompactionStrategy(int maxTurns)
: base(new SlidingWindowReducer(maxTurns))
{
this._maxTurns = maxTurns;
}
/// <inheritdoc/>
public override bool ShouldCompact(CompactionMetric metrics) =>
metrics.UserTurnCount > this._maxTurns;
/// <summary>
/// An <see cref="IChatReducer"/> that keeps system messages and the last N user turns
/// with all their associated response groups.
/// </summary>
private sealed class SlidingWindowReducer(int maxTurns) : IChatReducer
{
public Task<IEnumerable<ChatMessage>> ReduceAsync(
IEnumerable<ChatMessage> messages,
CancellationToken cancellationToken = default)
{
IReadOnlyList<ChatMessage> messageList = [.. messages];
IReadOnlyList<ChatMessageGroup> 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<int> 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<ChatMessage> 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<IEnumerable<ChatMessage>>(result);
}
}
}
@@ -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;
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// The strategy protects system messages and the most recent <c>preserveRecentGroups</c>
/// non-system groups. All older groups are collected and sent to the <see cref="IChatClient"/>
/// for summarization. The resulting summary replaces those messages as a single assistant message.
/// </para>
/// </remarks>
public class SummarizationCompactionStrategy : ChatHistoryCompactionStrategy
{
private readonly int _maxTokens;
/// <summary>
/// The default summarization prompt used when none is provided.
/// </summary>
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.
""";
/// <summary>
/// Initializes a new instance of the <see cref="SummarizationCompactionStrategy"/> class.
/// </summary>
/// <param name="chatClient">The <see cref="IChatClient"/> to use for generating summaries. A smaller, faster model is recommended.</param>
/// <param name="maxTokens">The maximum token budget. Summarization is triggered when the token count exceeds this value.</param>
/// <param name="preserveRecentGroups">
/// The number of most-recent non-system message groups to protect from summarization.
/// Defaults to 4, preserving the current and recent exchanges.
/// </param>
/// <param name="summarizationPrompt">
/// An optional custom system prompt for the summarization LLM call. When <see langword="null"/>,
/// a default prompt that emphasizes fact-preservation is used.
/// </param>
public SummarizationCompactionStrategy(
IChatClient chatClient,
int maxTokens,
int preserveRecentGroups = 4,
string? summarizationPrompt = null)
: base(new SummarizationReducer(chatClient, preserveRecentGroups, summarizationPrompt ?? DefaultSummarizationPrompt))
{
this._maxTokens = maxTokens;
}
/// <inheritdoc/>
public override bool ShouldCompact(CompactionMetric metrics) =>
metrics.TokenCount > this._maxTokens;
/// <summary>
/// An <see cref="IChatReducer"/> that sends older message groups to an LLM for summarization,
/// then replaces them with a single summary message.
/// </summary>
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<IEnumerable<ChatMessage>> ReduceAsync(
IEnumerable<ChatMessage> messages,
CancellationToken cancellationToken = default)
{
IReadOnlyList<ChatMessage> messageList = [.. messages];
IReadOnlyList<ChatMessageGroup> groups = CurrentMetrics.Groups;
List<ChatMessageGroup> 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<ChatMessage> 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<ChatMessage> 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<ChatMessage> 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;
}
}
}
@@ -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;
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// <para>
/// This is the gentlest compaction strategy — it does not remove any user messages or
/// plain assistant responses. It only targets <see cref="ChatMessageGroupKind.AssistantToolGroup"/>
/// entries outside the protected recent window, replacing each multi-message group
/// (assistant call + tool results) with a single assistant message like
/// <c>[Tool calls: get_weather, search_docs]</c>.
/// </para>
/// <para>
/// The trigger condition fires only when token count exceeds <c>maxTokens</c> and
/// there is at least one tool call in the conversation.
/// </para>
/// </remarks>
public class ToolResultCompactionStrategy : ChatHistoryCompactionStrategy
{
private readonly int _maxTokens;
/// <summary>
/// Initializes a new instance of the <see cref="ToolResultCompactionStrategy"/> class.
/// </summary>
/// <param name="maxTokens">The maximum token budget. Tool groups are collapsed when the token count exceeds this value.</param>
/// <param name="preserveRecentGroups">
/// 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.
/// </param>
public ToolResultCompactionStrategy(int maxTokens, int preserveRecentGroups = 2)
: base(new ToolResultClearingReducer(preserveRecentGroups))
{
this._maxTokens = maxTokens;
}
/// <inheritdoc/>
public override bool ShouldCompact(CompactionMetric metrics) =>
metrics.TokenCount > this._maxTokens && metrics.ToolCallCount > 0;
/// <summary>
/// An <see cref="IChatReducer"/> that collapses <see cref="ChatMessageGroupKind.AssistantToolGroup"/>
/// entries into single summary messages, preserving the most recent groups.
/// </summary>
private sealed class ToolResultClearingReducer(int preserveRecentGroups) : IChatReducer
{
public Task<IEnumerable<ChatMessage>> ReduceAsync(
IEnumerable<ChatMessage> messages,
CancellationToken cancellationToken = default)
{
IReadOnlyList<ChatMessage> messageList = [.. messages];
IReadOnlyList<ChatMessageGroup> groups = CurrentMetrics.Groups;
List<ChatMessageGroup> nonSystemGroups = groups.Where(g => g.Kind != ChatMessageGroupKind.System).ToList();
int protectedFromIndex = Math.Max(0, nonSystemGroups.Count - preserveRecentGroups);
HashSet<int> protectedGroupStarts = [];
for (int i = protectedFromIndex; i < nonSystemGroups.Count; i++)
{
protectedGroupStarts.Add(nonSystemGroups[i].StartIndex);
}
List<ChatMessage> 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<string> 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<IEnumerable<ChatMessage>>(anyCollapsed ? result : messageList);
}
}
}
@@ -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;
/// <summary>
/// A compaction strategy that removes the oldest message groups until the estimated
/// token count is within a specified budget.
/// </summary>
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// The trigger condition fires only when the current token count exceeds <c>maxTokens</c>.
/// </para>
/// </remarks>
public class TruncationCompactionStrategy : ChatHistoryCompactionStrategy
{
private readonly int _maxTokens;
/// <summary>
/// Initializes a new instance of the <see cref="TruncationCompactionStrategy"/> class.
/// </summary>
/// <param name="maxTokens">The maximum token budget. Groups are removed until the token count is at or below this value.</param>
/// <param name="preserveRecentGroups">
/// 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.
/// </param>
public TruncationCompactionStrategy(int maxTokens, int preserveRecentGroups = 1)
: base(new TruncationReducer(preserveRecentGroups))
{
this._maxTokens = maxTokens;
}
/// <inheritdoc/>
public override bool ShouldCompact(CompactionMetric metrics) =>
metrics.TokenCount > this._maxTokens;
/// <summary>
/// An <see cref="IChatReducer"/> that removes the oldest non-system message groups,
/// keeping at least the most recent group.
/// </summary>
private sealed class TruncationReducer(int preserveRecentGroups) : IChatReducer
{
public Task<IEnumerable<ChatMessage>> ReduceAsync(
IEnumerable<ChatMessage> messages,
CancellationToken cancellationToken = default)
{
IReadOnlyList<ChatMessage> messageList = [.. messages];
List<ChatMessageGroup> removableGroups = CurrentMetrics.Groups.Where(g => g.Kind != ChatMessageGroupKind.System).ToList();
if (removableGroups.Count == 0)
{
return Task.FromResult<IEnumerable<ChatMessage>>(messageList);
}
// Remove oldest non-system groups, keeping at least preserveRecentGroups.
int maxRemovable = removableGroups.Count - preserveRecentGroups;
if (maxRemovable <= 0)
{
return Task.FromResult<IEnumerable<ChatMessage>>(messageList);
}
HashSet<int> removedGroupStarts = [];
for (int ri = 0; ri < maxRemovable; ri++)
{
removedGroupStarts.Add(removableGroups[ri].StartIndex);
}
List<ChatMessage> 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<IEnumerable<ChatMessage>>(messagesToKeep);
}
}
}
@@ -31,6 +31,10 @@
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFrameworkIdentifier)' != '.NETCoreApp'">
<PackageReference Include="Microsoft.Bcl.HashCode" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="Microsoft.Agents.AI.Abstractions.UnitTests" />
</ItemGroup>
@@ -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<ChatMessage> 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<ChatMessage> 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<ChatMessage> 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<IChatHistoryMetricsCalculator> calcMock = new();
calcMock
.Setup(c => c.Calculate(Moq.It.IsAny<IReadOnlyList<ChatMessage>>()))
.Returns(new CompactionMetric { MessageCount = 42 });
ChatHistoryCompactionPipeline pipeline = new(calcMock.Object, []);
List<ChatMessage> 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<IReadOnlyList<ChatMessage>>()), Moq.Times.AtLeast(2));
}
[Fact]
public async Task CompactAsync_NonReadOnlyListMessages_WorksAsync()
{
ChatHistoryCompactionPipeline pipeline = new([new RemoveFirstMessageStrategy()]);
NonReadOnlyList<ChatMessage> 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<ChatMessage> result = await ((IChatReducer)pipeline).ReduceAsync(messages, default);
List<ChatMessage> 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<ChatMessage> result = await ((IChatReducer)pipeline).ReduceAsync(messages, default);
Assert.Equal(2, result.Count());
}
}
@@ -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<ChatMessage> 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<ChatMessage> 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<IChatReducer> reducerMock = new();
reducerMock
.Setup(r => r.ReduceAsync(It.IsAny<IEnumerable<ChatMessage>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((IEnumerable<ChatMessage> msgs, CancellationToken _) => msgs.Skip(1));
TestCompactionStrategy strategy = new(reducerMock.Object);
List<ChatMessage> 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<IEnumerable<ChatMessage>>(), It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task ReducerNoChange_ReturnsFalseAsync()
{
Mock<IChatReducer> reducerMock = new();
reducerMock
.Setup(r => r.ReduceAsync(It.IsAny<IEnumerable<ChatMessage>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((IEnumerable<ChatMessage> msgs, CancellationToken _) => msgs);
TestCompactionStrategy strategy = new(reducerMock.Object);
List<ChatMessage> 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<IChatReducer> reducerMock = new();
TestCompactionStrategy strategy = new(reducerMock.Object);
Assert.Same(reducerMock.Object, strategy.Reducer);
}
[Fact]
public void DefaultName_IsReducerTypeName()
{
Mock<IChatReducer> 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<IChatReducer> reducerMock = new();
TestCompactionStrategy strategy = new(reducerMock.Object);
CompactionMetric metrics = new() { TokenCount = 100 };
Assert.True(strategy.ShouldCompact(metrics));
}
[Fact]
public void ConditionDelegate_ReturnsFalse_ShouldCompactReturnsFalse()
{
Mock<IChatReducer> 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<ChatMessage> 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<InvalidOperationException>(() => 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;
}
}
@@ -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);
}
}
@@ -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<CompactionResult> 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);
}
}
@@ -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);
}
}
@@ -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<ChatMessage> 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<ChatMessage> 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<string, object?> { ["city"] = "NYC" }),
new FunctionCallContent("call2", "get_time"),
]);
List<ChatMessage> 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<ChatMessage> 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<ChatMessage> 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<ChatMessage> 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<ChatMessage> 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<ChatMessage> 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<ChatMessage> 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<ChatMessage> 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<ChatMessage> messages =
[
new(ChatRole.System, "You are a helpful assistant"),
];
IReadOnlyList<ChatMessageGroup> 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<string, object?> { ["city"] = "NYC" }),
]);
ChatMessage toolResult = new(ChatRole.Tool, [
new FunctionResultContent("call1", "Sunny, 72°F"),
]);
List<ChatMessage> messages =
[
new(ChatRole.User, "What's the weather?"),
assistantMsg,
toolResult,
];
IReadOnlyList<ChatMessageGroup> 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<ChatMessage> messages = [assistantMsg, tool1, tool2];
IReadOnlyList<ChatMessageGroup> 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<ChatMessage> 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<ChatMessageGroup> 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<ChatMessage> messages =
[
new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c1", "orphan result")]),
];
IReadOnlyList<ChatMessageGroup> 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<ChatMessage> messages =
[
new(new ChatRole("custom"), "custom message"),
];
IReadOnlyList<ChatMessageGroup> 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<ChatMessage> messages = [msg];
IReadOnlyList<ChatMessageGroup> groups = calculator.Calculate(messages).Groups;
Assert.Single(groups);
Assert.Equal(ChatMessageGroupKind.AssistantPlain, groups[0].Kind);
}
}
@@ -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;
/// <summary>
/// Provides a way to set <see cref="AIAgent.CurrentRunContext"/> in unit tests
/// so that the underlying <c>AsyncLocal</c> is populated for code that reads it.
/// </summary>
internal static class AgentRunContextHarness
{
private static readonly ContextAgentShim s_instance = new();
/// <summary>
/// Sets <see cref="AIAgent.CurrentRunContext"/> and invokes the provided action.
/// </summary>
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<AgentSession> CreateSessionCoreAsync(CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
protected override ValueTask<JsonElement> SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
protected override Task<AgentResponse> RunCoreAsync(IEnumerable<ChatMessage> messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
protected override IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(IEnumerable<ChatMessage> messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
}
}
@@ -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<IEnumerable<ChatMessage>> ReduceAsync(IEnumerable<ChatMessage> messages, CancellationToken cancellationToken = default)
=> Task.FromResult(messages);
}
}
@@ -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;
/// <summary>
/// An IList&lt;T&gt; that does NOT implement IReadOnlyList&lt;T&gt;,
/// used to test the defensive <c>as IReadOnlyList&lt;T&gt; ?? fallback</c> patterns.
/// </summary>
internal sealed class NonReadOnlyList<T> : IList<T>
{
private readonly List<T> _inner;
public NonReadOnlyList(IEnumerable<T> 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<T> 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();
}
@@ -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<IEnumerable<ChatMessage>> ReduceAsync(IEnumerable<ChatMessage> messages, CancellationToken cancellationToken = default)
{
List<ChatMessage> list = messages.ToList();
if (list.Count > 1)
{
list.RemoveAt(0);
}
return Task.FromResult<IEnumerable<ChatMessage>>(list);
}
}
}
@@ -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());
}
}
@@ -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<ChatMessage> 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<ChatMessage> 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<ChatMessage> 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<ChatMessage> 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<ChatMessage> 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<ChatMessage> 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);
}
}
@@ -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<IChatClient> 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<IChatClient> 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<IChatClient> chatClientMock = new();
SummarizationCompactionStrategy strategy = new(chatClientMock.Object, maxTokens: 100000);
List<ChatMessage> 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<IEnumerable<ChatMessage>>(), It.IsAny<ChatOptions>(), It.IsAny<CancellationToken>()),
Times.Never);
}
[Fact]
public async Task SummarizesOldGroupsAsync()
{
Mock<IChatClient> chatClientMock = new();
chatClientMock
.Setup(c => c.GetResponseAsync(It.IsAny<IEnumerable<ChatMessage>>(), It.IsAny<ChatOptions>(), It.IsAny<CancellationToken>()))
.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<ChatMessage> 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<IChatClient> chatClientMock = new();
chatClientMock
.Setup(c => c.GetResponseAsync(It.IsAny<IEnumerable<ChatMessage>>(), It.IsAny<ChatOptions>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "Summary of earlier discussion.")));
SummarizationCompactionStrategy strategy = new(chatClientMock.Object, maxTokens: 1, preserveRecentGroups: 1);
List<ChatMessage> 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<IChatClient> chatClientMock = new();
SummarizationCompactionStrategy strategy = new(chatClientMock.Object, maxTokens: 1, preserveRecentGroups: 10);
List<ChatMessage> 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<IEnumerable<ChatMessage>>(), It.IsAny<ChatOptions>(), It.IsAny<CancellationToken>()),
Times.Never);
}
[Fact]
public async Task CustomPrompt_UsedInRequestAsync()
{
const string CustomPrompt = "Summarize briefly.";
List<ChatMessage>? capturedMessages = null;
Mock<IChatClient> chatClientMock = new();
chatClientMock
.Setup(c => c.GetResponseAsync(It.IsAny<IEnumerable<ChatMessage>>(), It.IsAny<ChatOptions>(), It.IsAny<CancellationToken>()))
.Callback<IEnumerable<ChatMessage>, 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<ChatMessage> 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<IChatClient> chatClientMock = new();
chatClientMock
.Setup(c => c.GetResponseAsync(It.IsAny<IEnumerable<ChatMessage>>(), It.IsAny<ChatOptions>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, (string?)null)));
SummarizationCompactionStrategy strategy = new(chatClientMock.Object, maxTokens: 1, preserveRecentGroups: 1);
List<ChatMessage> 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);
}
}
@@ -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<ChatMessage> 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<ChatMessage> 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<ChatMessage> 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<ChatMessage> 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<ChatMessage> 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<ChatMessage> 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);
}
}
@@ -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<ChatMessage> 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<ChatMessage> 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<ChatMessage> 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<ChatMessage> 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<ChatMessage> 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);
}
}