Checkpoint

This commit is contained in:
Chris Rickman
2026-03-02 22:00:21 -08:00
Unverified
parent 42d424587d
commit d6c4dbea96
30 changed files with 740 additions and 525 deletions
@@ -13,6 +13,7 @@ using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Compaction;
using Microsoft.Extensions.AI;
using static Microsoft.Agents.AI.Compaction.ChatHistoryCompactionPipeline;
var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
@@ -39,33 +40,45 @@ static string LookupPrice([Description("The product name to look up.")] string p
};
// Configure the compaction pipeline with one of each strategy, ordered least to most aggressive.
const int MaxTokens = 512;
const int MaxTurns = 4;
//const int MaxTokens = 512;
//const int MaxTurns = 4;
ChatHistoryCompactionPipeline compactionPipeline =
new(// 1. Gentle: collapse old tool-call groups into short summaries like "[Tool calls: LookupPrice]"
new ToolResultCompactionStrategy(MaxTokens, preserveRecentGroups: 2),
//new(// 1. Gentle: collapse old tool-call groups into short summaries like "[Tool calls: LookupPrice]"
// new ToolResultCompactionStrategy(MaxTokens, preserveRecentGroups: 2),
// 2. Moderate: use an LLM to summarize older conversation spans into a concise message
new SummarizationCompactionStrategy(summarizerChatClient, MaxTokens, preserveRecentGroups: 2),
// // 2. Moderate: use an LLM to summarize older conversation spans into a concise message
// new SummarizationCompactionStrategy(summarizerChatClient, MaxTokens, preserveRecentGroups: 2),
// 3. Aggressive: keep only the last N user turns and their responses
new SlidingWindowCompactionStrategy(MaxTurns),
// // 3. Aggressive: keep only the last N user turns and their responses
// new SlidingWindowCompactionStrategy(MaxTurns),
// 4. Emergency: drop oldest groups until under the token budget
new TruncationCompactionStrategy(MaxTokens, preserveRecentGroups: 1));
// // 4. Emergency: drop oldest groups until under the token budget
// new TruncationCompactionStrategy(MaxTokens, preserveRecentGroups: 1));
Create(
Approach.Balanced,
Size.Compact,
summarizerChatClient);
// Create the agent with an in-memory chat history provider whose reducer is the compaction pipeline.
AIAgent agent = agentChatClient.AsAIAgent(new ChatClientAgentOptions
{
Name = "ShoppingAssistant",
ChatOptions = new()
{
Instructions = "You are a helpful shopping assistant. Help the user look up prices and compare products.",
Tools = [AIFunctionFactory.Create(LookupPrice)],
},
ChatHistoryProvider = new InMemoryChatHistoryProvider(new() { ChatReducer = compactionPipeline }),
});
AIAgent agent =
agentChatClient.AsAIAgent(
new ChatClientAgentOptions
{
Name = "ShoppingAssistant",
ChatOptions = new()
{
Instructions =
"""
You are a helpful, but long winded, shopping assistant.
Help the user look up prices and compare products.
When responding, Be sure to be extra descriptive and use as
many words as possible without sounding ridiculous.
""",
Tools = [AIFunctionFactory.Create(LookupPrice)],
},
ChatHistoryProvider = new InMemoryChatHistoryProvider(new() { ChatReducer = compactionPipeline }),
});
AgentSession session = await agent.CreateSessionAsync();
@@ -87,6 +100,7 @@ string[] prompts =
"Which product is the cheapest?",
"Can you compare the laptop and the keyboard for me?",
"What was the first product I asked about?",
"Thank you!",
];
foreach (string prompt in prompts)
@@ -0,0 +1,102 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Compaction;
public partial class ChatHistoryCompactionPipeline
{
/// <summary>
/// %%% COMMENT
/// </summary>
public enum Size
{
/// <summary>
/// %%% COMMENT
/// </summary>
Compact,
/// <summary>
/// %%% COMMENT
/// </summary>
Adequate,
/// <summary>
/// %%% COMMENT
/// </summary>
Accomodating,
}
/// <summary>
/// %%% COMMENT
/// </summary>
public enum Approach
{
/// <summary>
/// %%% COMMENT
/// </summary>
Aggressive,
/// <summary>
/// %%% COMMENT
/// </summary>
Balanced,
/// <summary>
/// %%% COMMENT
/// </summary>
Gentle,
}
/// <summary>
/// %%% COMMENT
/// </summary>
/// <param name="approach"></param>
/// <param name="size"></param>
/// <param name="chatClient"></param>
/// <returns></returns>
/// <exception cref="NotImplementedException"></exception>
public static ChatHistoryCompactionPipeline Create(Approach approach, Size size, IChatClient chatClient) =>
approach switch
{
Approach.Aggressive => CreateAgressive(size, chatClient),
Approach.Balanced => CreateBalanced(size),
Approach.Gentle => CreateGentle(size),
_ => throw new NotImplementedException(), // %%% EXCEPTION
};
private static ChatHistoryCompactionPipeline CreateAgressive(Size size, IChatClient chatClient) =>
new(// 1. Gentle: collapse old tool-call groups into short summaries like "[Tool calls: LookupPrice]"
new ToolResultCompactionStrategy(MaxTokens(size), preserveRecentGroups: 2),
// 2. Moderate: use an LLM to summarize older conversation spans into a concise message
new SummarizationCompactionStrategy(chatClient, MaxTokens(size), preserveRecentGroups: 2),
// 3. Aggressive: keep only the last N user turns and their responses
new SlidingWindowCompactionStrategy(MaxTurns(size)),
// 4. Emergency: drop oldest groups until under the token budget
new TruncationCompactionStrategy(MaxTokens(size), preserveRecentGroups: 1));
private static ChatHistoryCompactionPipeline CreateBalanced(Size size) =>
new(// 1. Gentle: collapse old tool-call groups into short summaries like "[Tool calls: LookupPrice]"
new ToolResultCompactionStrategy(MaxTokens(size), preserveRecentGroups: 2),
// 2. Aggressive: keep only the last N user turns and their responses
new SlidingWindowCompactionStrategy(MaxTurns(size)));
private static ChatHistoryCompactionPipeline CreateGentle(Size size) =>
new(// 1. Gentle: collapse old tool-call groups into short summaries like "[Tool calls: LookupPrice]"
new ToolResultCompactionStrategy(MaxTokens(size), preserveRecentGroups: 2));
private static int MaxTokens(Size size) =>
size switch
{
Size.Compact => 500,
Size.Adequate => 1000,
Size.Accomodating => 2000,
_ => throw new NotImplementedException(), // %%% EXCEPTION
};
private static int MaxTurns(Size size) =>
size switch
{
Size.Compact => 10,
Size.Adequate => 50,
Size.Accomodating => 100,
_ => throw new NotImplementedException(), // %%% EXCEPTION
};
}
@@ -1,7 +1,8 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
@@ -24,7 +25,7 @@ namespace Microsoft.Agents.AI.Compaction;
/// accepted (e.g., <see cref="InMemoryChatHistoryProviderOptions.ChatReducer"/>).
/// </para>
/// </remarks>
public class ChatHistoryCompactionPipeline : IChatReducer
public partial class ChatHistoryCompactionPipeline : IChatReducer
{
private readonly ChatHistoryCompactionStrategy[] _strategies;
private readonly IChatHistoryMetricsCalculator _metricsCalculator;
@@ -52,7 +53,7 @@ public class ChatHistoryCompactionPipeline : IChatReducer
IChatHistoryMetricsCalculator? metricsCalculator,
params IEnumerable<ChatHistoryCompactionStrategy> strategies)
{
this._strategies = Throw.IfNull(strategies).ToArray();
this._strategies = [.. Throw.IfNull(strategies)];
this._metricsCalculator = metricsCalculator ?? DefaultChatHistoryMetricsCalculator.Instance;
}
@@ -66,9 +67,9 @@ public class ChatHistoryCompactionPipeline : IChatReducer
IEnumerable<ChatMessage> messages,
CancellationToken cancellationToken = default)
{
List<ChatMessage> messageList = messages.ToList(); // %%% HAXX
await this.CompactAsync(messageList, cancellationToken).ConfigureAwait(false);
return messageList;
List<ChatMessage> messageBuffer = messages is List<ChatMessage> messageList ? messageList : [.. messages];
await this.CompactAsync(messageBuffer, cancellationToken).ConfigureAwait(false);
return messageBuffer;
}
/// <summary>
@@ -77,26 +78,37 @@ public class ChatHistoryCompactionPipeline : IChatReducer
/// <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,
public async ValueTask<CompactionPipelineResult> CompactAsync(
List<ChatMessage> messages,
CancellationToken cancellationToken = default)
{
Throw.IfNull(messages);
IReadOnlyList<ChatMessage> readOnlyMessages = messages as IReadOnlyList<ChatMessage> ?? [.. messages]; // %%% TYPE CONSISTENCY
CompactionMetric overallBefore = this._metricsCalculator.Calculate(readOnlyMessages);
ChatHistoryMetric overallBefore = this._metricsCalculator.Calculate(messages);
List<CompactionResult> results = new(this._strategies.Length);
Debug.WriteLine($"COMPACTION: BEGIN x{overallBefore.MessageCount}/#{overallBefore.UserTurnCount} ({overallBefore.TokenCount} tokens)");
List<CompactionResult> compactionResults = new(this._strategies.Length);
Stopwatch timer = new();
TimeSpan startTime = TimeSpan.Zero;
ChatHistoryMetric overallAfter = overallBefore;
ChatHistoryMetric currentBefore = overallBefore;
foreach (ChatHistoryCompactionStrategy strategy in this._strategies)
{
CompactionResult result = await strategy.CompactAsync(messages, this._metricsCalculator, cancellationToken).ConfigureAwait(false);
results.Add(result);
// %%% VERBOSE - Debug.WriteLine($"COMPACTION: {strategy.Name} START");
timer.Start();
ChatHistoryCompactionStrategy.s_currentMetrics.Value = currentBefore;
CompactionResult strategyResult = await strategy.CompactAsync(messages, this._metricsCalculator, cancellationToken).ConfigureAwait(false);
timer.Stop();
TimeSpan elapsedTime = timer.Elapsed - startTime;
// %%% VERBOSE - Debug.WriteLine($"COMPACTION: {strategy.Name} FINISH [{elapsedTime}]");
compactionResults.Add(strategyResult);
overallAfter = currentBefore = strategyResult.After;
}
readOnlyMessages = messages as IReadOnlyList<ChatMessage> ?? [.. messages];
CompactionMetric overallAfter = this._metricsCalculator.Calculate(readOnlyMessages);
Debug.WriteLineIf(overallBefore.TokenCount != overallAfter.TokenCount, $"COMPACTION: TOTAL [{timer.Elapsed}] {overallBefore.TokenCount} => {overallAfter.TokenCount} tokens");
return new(overallBefore, overallAfter, results);
return new(overallBefore, overallAfter, compactionResults);
}
}
@@ -2,8 +2,7 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
@@ -21,7 +20,7 @@ namespace Microsoft.Agents.AI.Compaction;
/// while the strategy adds:
/// <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>
/// <item><description>Before/after <see cref="ChatHistoryMetric"/> reporting via <see cref="CompactionResult"/>.</description></item>
/// </list>
/// </para>
/// <para>
@@ -36,7 +35,7 @@ namespace Microsoft.Agents.AI.Compaction;
/// </remarks>
public abstract class ChatHistoryCompactionStrategy
{
private static readonly AsyncLocal<CompactionMetric> s_currentMetrics = new();
internal static readonly AsyncLocal<ChatHistoryMetric> s_currentMetrics = new();
/// <summary>
/// Initializes a new instance of the <see cref="ChatHistoryCompactionStrategy"/> class.
@@ -48,9 +47,9 @@ public abstract class ChatHistoryCompactionStrategy
}
/// <summary>
/// Exposes the current <see cref="CompactionMetric"/> for the executing strategy, allowing <see cref="Reducer"/> to make informed decisions.
/// Exposes the current <see cref="ChatHistoryMetric"/> 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)}.");
protected static ChatHistoryMetric CurrentMetrics => s_currentMetrics.Value ?? throw new InvalidOperationException($"No active {nameof(ChatHistoryCompactionStrategy)}.");
/// <summary>
/// Gets the <see cref="IChatReducer"/> that performs the actual message compaction.
@@ -72,48 +71,50 @@ public abstract class ChatHistoryCompactionStrategy
/// <returns>
/// <see langword="true"/> to proceed with compaction; <see langword="false"/> to skip.
/// </returns>
public abstract bool ShouldCompact(CompactionMetric metrics);
protected abstract bool ShouldCompact(ChatHistoryMetric 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="history">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,
internal async ValueTask<CompactionResult> CompactAsync(
List<ChatMessage> history,
IChatHistoryMetricsCalculator metricsCalculator,
CancellationToken cancellationToken = default)
{
messages = Throw.IfNull(messages);
Throw.IfNull(metricsCalculator);
Throw.IfNull(history);
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))
ChatHistoryMetric beforeMetrics = CurrentMetrics;
if (!this.ShouldCompact(beforeMetrics))
{
return CompactionResult.Skipped(this.Name, before);
// %%% VERBOSE - Debug.WriteLine($"COMPACTION: {this.Name} - Skipped");
return CompactionResult.Skipped(this.Name, beforeMetrics);
}
ChatMessage[] reduced = (await this.Reducer.ReduceAsync(snapshot, cancellationToken).ConfigureAwait(false)).ToArray();
Debug.WriteLine($"COMPACTION: {this.Name} - Reducing");
bool modified = reduced.Length != snapshot.Count;
IEnumerable<ChatMessage> reducerResult = await this.Reducer.ReduceAsync(history, cancellationToken).ConfigureAwait(false);
// Ensure we have a concrete collection to avoid multiple enumerations of the reducer result, which could be costly if it's an iterator.
ChatMessage[] reducedCopy = [.. reducerResult];
bool modified = reducedCopy.Length != history.Count;
if (modified)
{
messages.Clear();
foreach (ChatMessage message in reduced)
{
messages.Add(message);
}
history.Clear();
history.AddRange(reducedCopy);
}
CompactionMetric after = modified
? metricsCalculator.Calculate(reduced)
: before;
ChatHistoryMetric afterMetrics = modified
? metricsCalculator.Calculate(reducedCopy)
: beforeMetrics;
return new(this.Name, applied: modified, before, after);
Debug.WriteLine($"COMPACTION: {this.Name} - Tokens {beforeMetrics.TokenCount} => {afterMetrics.TokenCount}");
return new(this.Name, applied: modified, beforeMetrics, afterMetrics);
}
}
@@ -7,7 +7,7 @@ namespace Microsoft.Agents.AI.Compaction;
/// <summary>
/// Immutable snapshot of conversation metrics used for compaction trigger evaluation and reporting.
/// </summary>
public sealed class CompactionMetric
public sealed class ChatHistoryMetric
{
/// <summary>
/// Gets the estimated token count across all messages.
@@ -0,0 +1,35 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using Microsoft.Extensions.AI;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Compaction;
/// <summary>
/// Represents a chat history compaction strategy that uses a condition function to determine when compaction should
/// occur.
/// </summary>
/// <remarks>
/// This strategy evaluates a user-provided condition against compaction metrics to decide whether to
/// compact the chat history. It is useful for scenarios where compaction should be triggered based on custom thresholds
/// or criteria. Inherits from ChatHistoryCompactionStrategy.
/// </remarks>
public class ChatReducerCompactionStrategy : ChatHistoryCompactionStrategy
{
private readonly Func<ChatHistoryMetric, bool> _condition;
/// <summary>
/// Initializes a new instance of the <see cref="ChatReducerCompactionStrategy"/> class.
/// </summary>
public ChatReducerCompactionStrategy(
IChatReducer reducer,
Func<ChatHistoryMetric, bool> condition)
: base(reducer)
{
this._condition = Throw.IfNull(condition);
}
/// <inheritdoc/>
protected override bool ShouldCompact(ChatHistoryMetric metrics) => this._condition(metrics);
}
@@ -18,8 +18,8 @@ public sealed class CompactionPipelineResult
/// <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,
ChatHistoryMetric before,
ChatHistoryMetric after,
IReadOnlyList<CompactionResult> strategyResults)
{
this.Before = Throw.IfNull(before);
@@ -30,12 +30,12 @@ public sealed class CompactionPipelineResult
/// <summary>
/// Gets the conversation metrics before any compaction strategy ran.
/// </summary>
public CompactionMetric Before { get; }
public ChatHistoryMetric Before { get; }
/// <summary>
/// Gets the conversation metrics after all compaction strategies ran.
/// </summary>
public CompactionMetric After { get; }
public ChatHistoryMetric After { get; }
/// <summary>
/// Gets the per-strategy results in execution order.
@@ -16,7 +16,7 @@ public sealed class CompactionResult
/// <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)
public CompactionResult(string strategyName, bool applied, ChatHistoryMetric before, ChatHistoryMetric after)
{
this.StrategyName = Throw.IfNullOrWhitespace(strategyName);
this.Applied = applied;
@@ -37,12 +37,12 @@ public sealed class CompactionResult
/// <summary>
/// Gets the conversation metrics before the strategy executed.
/// </summary>
public CompactionMetric Before { get; }
public ChatHistoryMetric Before { get; }
/// <summary>
/// Gets the conversation metrics after the strategy executed.
/// </summary>
public CompactionMetric After { get; }
public ChatHistoryMetric After { get; }
/// <summary>
/// Creates a <see cref="CompactionResult"/> representing a skipped strategy.
@@ -50,6 +50,6 @@ public sealed class CompactionResult
/// <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)
internal static CompactionResult Skipped(string strategyName, ChatHistoryMetric metrics)
=> new(strategyName, applied: false, metrics, metrics);
}
@@ -46,7 +46,7 @@ public sealed class DefaultChatHistoryMetricsCalculator : IChatHistoryMetricsCal
}
/// <inheritdoc/>
public CompactionMetric Calculate(IReadOnlyList<ChatMessage> messages)
public ChatHistoryMetric Calculate(IReadOnlyList<ChatMessage> messages)
{
if (messages is null || messages.Count == 0)
{
@@ -9,18 +9,18 @@ namespace Microsoft.Agents.AI.Compaction;
// and whether custom metrics calculators are a realistic extension point.
/// <summary>
/// Computes <see cref="CompactionMetric"/> for a list of messages.
/// Computes <see cref="ChatHistoryMetric"/> 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 ???
public interface IChatHistoryMetricsCalculator
{
/// <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);
/// <returns>A <see cref="ChatHistoryMetric"/> snapshot.</returns>
ChatHistoryMetric Calculate(IReadOnlyList<ChatMessage> messages);
}
@@ -1,6 +1,5 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
@@ -44,7 +43,7 @@ public class SlidingWindowCompactionStrategy : ChatHistoryCompactionStrategy
}
/// <inheritdoc/>
public override bool ShouldCompact(CompactionMetric metrics) =>
protected override bool ShouldCompact(ChatHistoryMetric metrics) =>
metrics.UserTurnCount > this._maxTurns;
/// <summary>
@@ -57,25 +56,21 @@ public class SlidingWindowCompactionStrategy : ChatHistoryCompactionStrategy
IEnumerable<ChatMessage> messages,
CancellationToken cancellationToken = default)
{
IReadOnlyList<ChatMessage> messageList = [.. messages];
IReadOnlyList<ChatMessage> messageList = [.. messages]; // %%% PERFORMANCE
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);
}
}
int[] turnGroupIndices =
[.. CurrentMetrics.Groups
.Select((group, index) => (group, index))
.Where(t => t.group.Kind == ChatMessageGroupKind.UserTurn)
.Select(t => t.index)];
// Keep the last maxTurns user turns and everything after the first kept turn
int firstKeptTurnIndex = turnGroupIndices.Count - maxTurns;
int firstKeptTurnIndex = turnGroupIndices.Length - maxTurns;
int firstKeptGroupIndex = turnGroupIndices[firstKeptTurnIndex];
List<ChatMessage> result = new(messageList.Count);
List<ChatMessage> result = new(messageList.Count); // %%% PERFORMANCE
for (int gi = 0; gi < groups.Count; gi++)
{
ChatMessageGroup group = groups[gi];
@@ -69,7 +69,7 @@ public class SummarizationCompactionStrategy : ChatHistoryCompactionStrategy
}
/// <inheritdoc/>
public override bool ShouldCompact(CompactionMetric metrics) =>
protected override bool ShouldCompact(ChatHistoryMetric metrics) =>
metrics.TokenCount > this._maxTokens;
/// <summary>
@@ -96,7 +96,7 @@ public class SummarizationCompactionStrategy : ChatHistoryCompactionStrategy
IReadOnlyList<ChatMessage> messageList = [.. messages];
IReadOnlyList<ChatMessageGroup> groups = CurrentMetrics.Groups;
List<ChatMessageGroup> nonSystemGroups = groups.Where(g => g.Kind != ChatMessageGroupKind.System).ToList();
List<ChatMessageGroup> nonSystemGroups = [.. groups.Where(g => g.Kind != ChatMessageGroupKind.System)];
int protectedFromIndex = Math.Max(0, nonSystemGroups.Count - this._preserveRecentGroups);
if (protectedFromIndex == 0)
@@ -29,6 +29,11 @@ namespace Microsoft.Agents.AI.Compaction;
/// </remarks>
public class ToolResultCompactionStrategy : ChatHistoryCompactionStrategy
{
/// <summary>
/// The default value for `preserveRecentGroups` used when constructing <see cref="ToolResultCompactionStrategy"/>.
/// </summary>
public const int DefaultPreserveRecentGroups = 2;
private readonly int _maxTokens;
/// <summary>
@@ -39,14 +44,14 @@ public class ToolResultCompactionStrategy : ChatHistoryCompactionStrategy
/// The number of most-recent non-system message groups to protect from collapsing.
/// Defaults to 2, ensuring the current turn's tool interactions remain visible.
/// </param>
public ToolResultCompactionStrategy(int maxTokens, int preserveRecentGroups = 2)
public ToolResultCompactionStrategy(int maxTokens, int preserveRecentGroups = DefaultPreserveRecentGroups)
: base(new ToolResultClearingReducer(preserveRecentGroups))
{
this._maxTokens = maxTokens;
}
/// <inheritdoc/>
public override bool ShouldCompact(CompactionMetric metrics) =>
protected override bool ShouldCompact(ChatHistoryMetric metrics) =>
metrics.TokenCount > this._maxTokens && metrics.ToolCallCount > 0;
/// <summary>
@@ -62,7 +67,7 @@ public class ToolResultCompactionStrategy : ChatHistoryCompactionStrategy
IReadOnlyList<ChatMessage> messageList = [.. messages];
IReadOnlyList<ChatMessageGroup> groups = CurrentMetrics.Groups;
List<ChatMessageGroup> nonSystemGroups = groups.Where(g => g.Kind != ChatMessageGroupKind.System).ToList();
List<ChatMessageGroup> nonSystemGroups = [.. groups.Where(g => g.Kind != ChatMessageGroupKind.System)];
int protectedFromIndex = Math.Max(0, nonSystemGroups.Count - preserveRecentGroups);
HashSet<int> protectedGroupStarts = [];
for (int i = protectedFromIndex; i < nonSystemGroups.Count; i++)
@@ -41,7 +41,7 @@ public class TruncationCompactionStrategy : ChatHistoryCompactionStrategy
}
/// <inheritdoc/>
public override bool ShouldCompact(CompactionMetric metrics) =>
protected override bool ShouldCompact(ChatHistoryMetric metrics) =>
metrics.TokenCount > this._maxTokens;
/// <summary>
@@ -56,15 +56,15 @@ public class TruncationCompactionStrategy : ChatHistoryCompactionStrategy
{
IReadOnlyList<ChatMessage> messageList = [.. messages];
List<ChatMessageGroup> removableGroups = CurrentMetrics.Groups.Where(g => g.Kind != ChatMessageGroupKind.System).ToList();
ChatMessageGroup[] removableGroups = [.. CurrentMetrics.Groups.Where(g => g.Kind != ChatMessageGroupKind.System)];
if (removableGroups.Count == 0)
if (removableGroups.Length == 0)
{
return Task.FromResult<IEnumerable<ChatMessage>>(messageList);
}
// Remove oldest non-system groups, keeping at least preserveRecentGroups.
int maxRemovable = removableGroups.Count - preserveRecentGroups;
int maxRemovable = removableGroups.Length - preserveRecentGroups;
if (maxRemovable <= 0)
{
@@ -14,11 +14,14 @@ public class ChatHistoryCompactionPipelineTests
[Fact]
public async Task EmptyStrategies_ReturnsUnmodifiedAsync()
{
// Arrange
ChatHistoryCompactionPipeline pipeline = new([]);
List<ChatMessage> messages = [new(ChatRole.User, "Hello")];
// Act
CompactionPipelineResult result = await pipeline.CompactAsync(messages);
// Assert
Assert.False(result.AnyApplied);
Assert.Equal(1, result.Before.MessageCount);
Assert.Equal(1, result.After.MessageCount);
@@ -28,6 +31,7 @@ public class ChatHistoryCompactionPipelineTests
[Fact]
public async Task ChainsStrategies_InOrderAsync()
{
// Arrange
ChatHistoryCompactionStrategy[] strategies =
[
new NeverCompactStrategy(),
@@ -40,8 +44,10 @@ public class ChatHistoryCompactionPipelineTests
new(ChatRole.User, "Second"),
];
// Act
CompactionPipelineResult result = await pipeline.CompactAsync(messages);
// Assert
Assert.True(result.AnyApplied);
Assert.Equal(2, result.StrategyResults.Count);
Assert.False(result.StrategyResults[0].Applied);
@@ -52,6 +58,7 @@ public class ChatHistoryCompactionPipelineTests
[Fact]
public async Task ReportsOverallMetricsAsync()
{
// Arrange
ChatHistoryCompactionPipeline pipeline = new([new RemoveFirstMessageStrategy()]);
List<ChatMessage> messages =
[
@@ -60,8 +67,10 @@ public class ChatHistoryCompactionPipelineTests
new(ChatRole.User, "Third"),
];
// Act
CompactionPipelineResult result = await pipeline.CompactAsync(messages);
// Assert
Assert.Equal(3, result.Before.MessageCount);
Assert.Equal(2, result.After.MessageCount);
}
@@ -69,51 +78,39 @@ public class ChatHistoryCompactionPipelineTests
[Fact]
public async Task CustomMetricsCalculator_IsUsedAsync()
{
// Arrange
Moq.Mock<IChatHistoryMetricsCalculator> calcMock = new();
calcMock
.Setup(c => c.Calculate(Moq.It.IsAny<IReadOnlyList<ChatMessage>>()))
.Returns(new CompactionMetric { MessageCount = 42 });
.Returns(new ChatHistoryMetric { MessageCount = 42 });
ChatHistoryCompactionPipeline pipeline = new(calcMock.Object, []);
List<ChatMessage> messages = [new(ChatRole.User, "Hello")];
// Act
CompactionPipelineResult result = await pipeline.CompactAsync(messages);
// Assert
Assert.Equal(42, result.Before.MessageCount);
calcMock.Verify(c => c.Calculate(Moq.It.IsAny<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);
calcMock.Verify(c => c.Calculate(Moq.It.IsAny<IReadOnlyList<ChatMessage>>()), Moq.Times.Once);
}
[Fact]
public async Task ReduceAsync_DelegatesCompactionAsync()
{
// Arrange
ChatHistoryCompactionPipeline pipeline = new([new RemoveFirstMessageStrategy()]);
ChatMessage[] messages =
List<ChatMessage> messages =
[
new(ChatRole.User, "First"),
new(ChatRole.User, "Second"),
new(ChatRole.User, "Third"),
];
IEnumerable<ChatMessage> result = await ((IChatReducer)pipeline).ReduceAsync(messages, default);
// Act
IEnumerable<ChatMessage> result = await pipeline.ReduceAsync(messages, default);
List<ChatMessage> resultList = result.ToList();
// Assert
Assert.Equal(2, resultList.Count);
Assert.Equal("Second", resultList[0].Text);
Assert.Equal("Third", resultList[1].Text);
@@ -122,16 +119,18 @@ public class ChatHistoryCompactionPipelineTests
[Fact]
public async Task ReduceAsync_EmptyStrategies_ReturnsAllMessagesAsync()
{
// Arrange
ChatHistoryCompactionPipeline pipeline = new([]);
ChatMessage[] messages =
[
new(ChatRole.User, "Hello"),
new(ChatRole.User, "World"),
];
IEnumerable<ChatMessage> result = await ((IChatReducer)pipeline).ReduceAsync(messages, default);
// Act
IEnumerable<ChatMessage> result = await pipeline.ReduceAsync(messages, default);
// Assert
Assert.Equal(2, result.Count());
}
}
@@ -17,28 +17,32 @@ public class ChatHistoryCompactionStrategyTests
[Fact]
public async Task ShouldCompactReturnsFalse_SkipsAsync()
{
NeverCompactStrategy strategy = new();
// Arrange
List<ChatMessage> messages = [new(ChatRole.User, "Hello")];
DefaultChatHistoryMetricsCalculator calculator = new();
NeverCompactStrategy strategy = new();
CompactionResult result = await strategy.CompactAsync(messages, calculator);
// Act
CompactionResult result = await RunCompactionStrategyAsync(strategy, messages);
// Assert
Assert.False(result.Applied);
}
[Fact]
public async Task ShouldCompactReturnsTrue_RunsCompactionAsync()
{
RemoveFirstMessageStrategy strategy = new();
// Arrange
List<ChatMessage> messages =
[
new(ChatRole.User, "First"),
new(ChatRole.User, "Second"),
];
DefaultChatHistoryMetricsCalculator calculator = new();
RemoveFirstMessageStrategy strategy = new();
CompactionResult result = await strategy.CompactAsync(messages, calculator);
// Act
CompactionResult result = await RunCompactionStrategyAsync(strategy, messages);
// Assert
Assert.True(result.Applied);
Assert.Single(messages);
Assert.Equal("Second", messages[0].Text);
@@ -47,23 +51,24 @@ public class ChatHistoryCompactionStrategyTests
}
[Fact]
public async Task DelegatesToIChatReducerAsync()
public async Task DelegatesToReducerAsync()
{
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);
// Arrange
List<ChatMessage> messages =
[
new(ChatRole.User, "First"),
new(ChatRole.User, "Second"),
];
DefaultChatHistoryMetricsCalculator calculator = new();
Mock<IChatReducer> reducerMock = new();
reducerMock
.Setup(r => r.ReduceAsync(It.IsAny<IEnumerable<ChatMessage>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((IEnumerable<ChatMessage> messages, CancellationToken _) => messages.Skip(1));
TestCompactionStrategy strategy = new(reducerMock.Object);
CompactionResult result = await strategy.CompactAsync(messages, calculator);
// Act
CompactionResult result = await RunCompactionStrategyAsync(strategy, messages);
// Assert
Assert.True(result.Applied);
Assert.Single(messages);
Assert.Equal("Second", messages[0].Text);
@@ -73,88 +78,55 @@ public class ChatHistoryCompactionStrategyTests
[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);
// Arrange
List<ChatMessage> messages =
[
new(ChatRole.User, "Hello"),
];
DefaultChatHistoryMetricsCalculator calculator = new();
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, shouldCompact: false);
CompactionResult result = await strategy.CompactAsync(messages, calculator);
// Act
CompactionResult result = await RunCompactionStrategyAsync(strategy, messages);
// Assert
Assert.False(result.Applied);
Assert.Single(messages);
}
[Fact]
public void ExposesReducer()
public void ReducerLifecycle()
{
// Arrange
Mock<IChatReducer> reducerMock = new();
// Act
TestCompactionStrategy strategy = new(reducerMock.Object);
// Assert
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);
Assert.Equal(reducerMock.Object.GetType().Name, strategy.Name);
}
[Fact]
public void CurrentMetrics_OutsideStrategy_Throws()
{
// Act & Assert
Assert.Throws<InvalidOperationException>(() => TestCompactionStrategy.GetCurrentMetrics());
}
public static async ValueTask<CompactionResult> RunCompactionStrategyAsync(ChatHistoryCompactionStrategy strategy, List<ChatMessage> messages)
{
// Act
ChatHistoryCompactionStrategy.s_currentMetrics.Value = DefaultChatHistoryMetricsCalculator.Instance.Calculate(messages);
return await strategy.CompactAsync(messages, DefaultChatHistoryMetricsCalculator.Instance);
}
private sealed class TestCompactionStrategy : ChatHistoryCompactionStrategy
{
private readonly bool _shouldCompact;
@@ -165,8 +137,8 @@ public class ChatHistoryCompactionStrategyTests
this._shouldCompact = shouldCompact;
}
public override bool ShouldCompact(CompactionMetric metrics) => this._shouldCompact;
protected override bool ShouldCompact(ChatHistoryMetric metrics) => this._shouldCompact;
public static CompactionMetric GetCurrentMetrics() => CurrentMetrics;
public static ChatHistoryMetric GetCurrentMetrics() => CurrentMetrics;
}
}
@@ -0,0 +1,114 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Compaction;
using Microsoft.Extensions.AI;
using Moq;
namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction;
public class ChatReducerCompactionStrategyTests : CompactionStrategyTestBase
{
[Fact]
public async Task ConditionFalse_SkipsAsync()
{
// Arrange
List<ChatMessage> messages = [new(ChatRole.User, "Hello")];
Mock<IChatReducer> reducerMock = new();
ChatReducerCompactionStrategy strategy = new(reducerMock.Object, _ => false);
// Act & Assert
await RunCompactionStrategySkippedAsync(strategy, messages);
// Assert
reducerMock.Verify(
r => r.ReduceAsync(It.IsAny<IEnumerable<ChatMessage>>(), It.IsAny<CancellationToken>()),
Times.Never);
}
[Fact]
public async Task ConditionTrue_RunsReducerAsync()
{
// Arrange
List<ChatMessage> messages =
[
new(ChatRole.User, "First"),
new(ChatRole.User, "Second"),
];
Mock<IChatReducer> reducerMock = new();
reducerMock
.Setup(r => r.ReduceAsync(It.IsAny<IEnumerable<ChatMessage>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((IEnumerable<ChatMessage> msgs, CancellationToken _) => msgs.Skip(1));
ChatReducerCompactionStrategy strategy = new(reducerMock.Object, _ => true);
// Act & Assert
await RunCompactionStrategyReducedAsync(strategy, messages, expectedCount: 1);
// Assert
Assert.Equal("Second", messages[0].Text);
reducerMock.Verify(
r => r.ReduceAsync(It.IsAny<IEnumerable<ChatMessage>>(), It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task ConditionReceivesMetricsAsync()
{
// Arrange
List<ChatMessage> messages =
[
new(ChatRole.User, "Hello"),
new(ChatRole.Assistant, "Hi"),
];
ChatHistoryMetric? capturedMetrics = null;
Mock<IChatReducer> reducerMock = new();
reducerMock
.Setup(r => r.ReduceAsync(It.IsAny<IEnumerable<ChatMessage>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((IEnumerable<ChatMessage> msgs, CancellationToken _) => msgs);
ChatReducerCompactionStrategy strategy = new(
reducerMock.Object,
metrics =>
{
capturedMetrics = metrics;
return false;
});
// Act & Assert
await RunCompactionStrategySkippedAsync(strategy, messages);
// Assert
Assert.NotNull(capturedMetrics);
Assert.Equal(2, capturedMetrics!.MessageCount);
}
[Fact]
public async Task ReducerNoChange_AppliedFalseAsync()
{
// Arrange
List<ChatMessage> messages = [new(ChatRole.User, "Hello")];
Mock<IChatReducer> reducerMock = new();
reducerMock
.Setup(r => r.ReduceAsync(It.IsAny<IEnumerable<ChatMessage>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((IEnumerable<ChatMessage> msgs, CancellationToken _) => msgs);
ChatReducerCompactionStrategy strategy = new(reducerMock.Object, _ => true);
// Act & Assert
await RunCompactionStrategySkippedAsync(strategy, messages);
}
[Fact]
public void Name_ReturnsReducerTypeName()
{
// Arrange
Mock<IChatReducer> reducerMock = new();
// Act
ChatReducerCompactionStrategy strategy = new(reducerMock.Object, _ => true);
// Assert
Assert.Equal(reducerMock.Object.GetType().Name, strategy.Name);
}
}
@@ -9,7 +9,10 @@ public class CompactionMetricTests
[Fact]
public void DefaultValues_AreZero()
{
CompactionMetric metrics = new();
// Arrange & Act
ChatHistoryMetric metrics = new();
// Assert
Assert.Equal(0, metrics.TokenCount);
Assert.Equal(0L, metrics.ByteCount);
Assert.Equal(0, metrics.MessageCount);
@@ -21,7 +24,8 @@ public class CompactionMetricTests
[Fact]
public void InitProperties_SetCorrectly()
{
CompactionMetric metrics = new()
// Arrange & Act
ChatHistoryMetric metrics = new()
{
TokenCount = 100,
ByteCount = 500,
@@ -30,6 +34,7 @@ public class CompactionMetricTests
UserTurnCount = 3
};
// Assert
Assert.Equal(100, metrics.TokenCount);
Assert.Equal(500L, metrics.ByteCount);
Assert.Equal(5, metrics.MessageCount);
@@ -11,13 +11,16 @@ public class CompactionPipelineResultTests
[Fact]
public void Properties_AreReadable()
{
CompactionMetric before = new() { MessageCount = 10 };
CompactionMetric after = new() { MessageCount = 5 };
// Arrange
ChatHistoryMetric before = new() { MessageCount = 10 };
ChatHistoryMetric after = new() { MessageCount = 5 };
CompactionResult strategyResult = new("Test", applied: true, before, after);
List<CompactionResult> results = [strategyResult];
// Act
CompactionPipelineResult pipelineResult = new(before, after, results);
// Assert
Assert.Same(before, pipelineResult.Before);
Assert.Same(after, pipelineResult.After);
Assert.Single(pipelineResult.StrategyResults);
@@ -26,21 +29,29 @@ public class CompactionPipelineResultTests
[Fact]
public void AnyApplied_AllFalse_ReturnsFalse()
{
CompactionMetric metrics = new() { MessageCount = 5 };
// Arrange
ChatHistoryMetric metrics = new() { MessageCount = 5 };
CompactionResult skipped = CompactionResult.Skipped("Skip", metrics);
// Act
CompactionPipelineResult result = new(metrics, metrics, [skipped]);
// Assert
Assert.False(result.AnyApplied);
}
[Fact]
public void AnyApplied_SomeTrue_ReturnsTrue()
{
CompactionMetric before = new() { MessageCount = 10 };
CompactionMetric after = new() { MessageCount = 5 };
// Arrange
ChatHistoryMetric before = new() { MessageCount = 10 };
ChatHistoryMetric after = new() { MessageCount = 5 };
CompactionResult applied = new("Applied", applied: true, before, after);
// Act
CompactionPipelineResult result = new(before, after, [applied]);
// Assert
Assert.True(result.AnyApplied);
}
}
@@ -9,9 +9,13 @@ public class CompactionResultTests
[Fact]
public void Skipped_HasSameBeforeAndAfter()
{
CompactionMetric metrics = new() { MessageCount = 5, TokenCount = 100 };
// Arrange
ChatHistoryMetric metrics = new() { MessageCount = 5, TokenCount = 100 };
// Act
CompactionResult result = CompactionResult.Skipped("Test", metrics);
// Assert
Assert.Equal("Test", result.StrategyName);
Assert.False(result.Applied);
Assert.Same(metrics, result.Before);
@@ -0,0 +1,40 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Compaction;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction;
public abstract class CompactionStrategyTestBase
{
public static async ValueTask<CompactionResult> RunCompactionStrategyReducedAsync(ChatHistoryCompactionStrategy strategy, List<ChatMessage> messages, int expectedCount)
{
// Act
ChatHistoryCompactionStrategy.s_currentMetrics.Value = DefaultChatHistoryMetricsCalculator.Instance.Calculate(messages);
CompactionResult result = await strategy.CompactAsync(messages, DefaultChatHistoryMetricsCalculator.Instance);
// Assert
Assert.True(result.Applied);
Assert.NotEqual(result.Before, result.After);
Assert.Equal(expectedCount, messages.Count);
return result;
}
public static async ValueTask<CompactionResult> RunCompactionStrategySkippedAsync(ChatHistoryCompactionStrategy strategy, List<ChatMessage> messages)
{
// Act
int initialCount = messages.Count;
ChatHistoryCompactionStrategy.s_currentMetrics.Value = DefaultChatHistoryMetricsCalculator.Instance.Calculate(messages);
CompactionResult result = await strategy.CompactAsync(messages, DefaultChatHistoryMetricsCalculator.Instance);
// Assert
Assert.False(result.Applied);
Assert.Equal(result.Before, result.After);
Assert.Equal(initialCount, messages.Count);
return result;
}
}
@@ -11,9 +11,13 @@ public class DefaultChatHistoryMetricsCalculatorTests
[Fact]
public void EmptyList_ReturnsZeroMetrics()
{
// Arrange
DefaultChatHistoryMetricsCalculator calculator = new();
CompactionMetric metrics = calculator.Calculate([]);
// Act
ChatHistoryMetric metrics = calculator.Calculate([]);
// Assert
Assert.Equal(0, metrics.TokenCount);
Assert.Equal(0L, metrics.ByteCount);
Assert.Equal(0, metrics.MessageCount);
@@ -24,6 +28,7 @@ public class DefaultChatHistoryMetricsCalculatorTests
[Fact]
public void CountsMessages()
{
// Arrange
DefaultChatHistoryMetricsCalculator calculator = new();
List<ChatMessage> messages =
[
@@ -31,14 +36,17 @@ public class DefaultChatHistoryMetricsCalculatorTests
new(ChatRole.Assistant, "Hi there"),
];
CompactionMetric metrics = calculator.Calculate(messages);
// Act
ChatHistoryMetric metrics = calculator.Calculate(messages);
// Assert
Assert.Equal(2, metrics.MessageCount);
}
[Fact]
public void CountsUserTurns()
{
// Arrange
DefaultChatHistoryMetricsCalculator calculator = new();
List<ChatMessage> messages =
[
@@ -48,14 +56,17 @@ public class DefaultChatHistoryMetricsCalculatorTests
new(ChatRole.Assistant, "Good"),
];
CompactionMetric metrics = calculator.Calculate(messages);
// Act
ChatHistoryMetric metrics = calculator.Calculate(messages);
// Assert
Assert.Equal(2, metrics.UserTurnCount);
}
[Fact]
public void CountsToolCalls()
{
// Arrange
DefaultChatHistoryMetricsCalculator calculator = new();
ChatMessage assistantMsg = new(ChatRole.Assistant, [
new FunctionCallContent("call1", "get_weather", new Dictionary<string, object?> { ["city"] = "NYC" }),
@@ -67,14 +78,17 @@ public class DefaultChatHistoryMetricsCalculatorTests
assistantMsg,
];
CompactionMetric metrics = calculator.Calculate(messages);
// Act
ChatHistoryMetric metrics = calculator.Calculate(messages);
// Assert
Assert.Equal(2, metrics.ToolCallCount);
}
[Fact]
public void ConsecutiveUserMessages_CountAsOneTurn()
{
// Arrange
DefaultChatHistoryMetricsCalculator calculator = new();
List<ChatMessage> messages =
[
@@ -83,22 +97,27 @@ public class DefaultChatHistoryMetricsCalculatorTests
new(ChatRole.Assistant, "Reply"),
];
CompactionMetric metrics = calculator.Calculate(messages);
// Act
ChatHistoryMetric metrics = calculator.Calculate(messages);
// Assert
Assert.Equal(1, metrics.UserTurnCount);
}
[Fact]
public void TokenCount_IsPositive()
{
// Arrange
DefaultChatHistoryMetricsCalculator calculator = new();
List<ChatMessage> messages =
[
new(ChatRole.User, "Hello world"),
];
CompactionMetric metrics = calculator.Calculate(messages);
// Act
ChatHistoryMetric metrics = calculator.Calculate(messages);
// Assert
Assert.True(metrics.TokenCount > 0);
Assert.True(metrics.ByteCount > 0);
}
@@ -106,9 +125,13 @@ public class DefaultChatHistoryMetricsCalculatorTests
[Fact]
public void NullInput_ReturnsZeroMetrics()
{
// Arrange
DefaultChatHistoryMetricsCalculator calculator = new();
CompactionMetric metrics = calculator.Calculate(null!);
// Act
ChatHistoryMetric metrics = calculator.Calculate(null!);
// Assert
Assert.Equal(0, metrics.TokenCount);
Assert.Equal(0L, metrics.ByteCount);
Assert.Equal(0, metrics.MessageCount);
@@ -118,45 +141,50 @@ public class DefaultChatHistoryMetricsCalculatorTests
[Fact]
public void InvalidCharsPerToken_UsesDefault()
{
// A non-positive charsPerToken should fall back to the default (4)
// Arrange
DefaultChatHistoryMetricsCalculator calculator = new(charsPerToken: 0);
List<ChatMessage> messages =
[
new(ChatRole.User, "Hello world"),
];
CompactionMetric metrics = calculator.Calculate(messages);
// Act
ChatHistoryMetric metrics = calculator.Calculate(messages);
// With default 4 chars/token: "Hello world" = 11 chars → 11/4=2 + 4 overhead = 6 tokens
// Assert
Assert.True(metrics.TokenCount > 0);
}
[Fact]
public void NullMessageText_HandledGracefully()
{
// Arrange
DefaultChatHistoryMetricsCalculator calculator = new();
// Message with no text content — Text returns null
ChatMessage msg = new() { Role = ChatRole.User };
List<ChatMessage> messages = [msg];
CompactionMetric metrics = calculator.Calculate(messages);
// Act
ChatHistoryMetric metrics = calculator.Calculate(messages);
// Assert
Assert.Equal(1, metrics.MessageCount);
// Null text → empty string → 0 bytes, only overhead tokens
Assert.True(metrics.TokenCount > 0); // per-message overhead
Assert.True(metrics.TokenCount > 0);
Assert.Equal(0L, metrics.ByteCount);
}
[Fact]
public void NullContents_SkipsToolCounting()
{
// Arrange
DefaultChatHistoryMetricsCalculator calculator = new();
ChatMessage msg = new(ChatRole.User, "text");
msg.Contents = null!;
List<ChatMessage> messages = [msg];
CompactionMetric metrics = calculator.Calculate(messages);
// Act
ChatHistoryMetric metrics = calculator.Calculate(messages);
// Assert
Assert.Equal(1, metrics.MessageCount);
Assert.Equal(0, metrics.ToolCallCount);
}
@@ -164,16 +192,18 @@ public class DefaultChatHistoryMetricsCalculatorTests
[Fact]
public void MessageWithOnlyNonTextContent_NullTextHandled()
{
// Arrange
DefaultChatHistoryMetricsCalculator calculator = new();
// FunctionCallContent-only message has null Text
ChatMessage msg = new(ChatRole.Assistant,
[
new FunctionCallContent("c1", "func"),
]);
List<ChatMessage> messages = [msg];
CompactionMetric metrics = calculator.Calculate(messages);
// Act
ChatHistoryMetric metrics = calculator.Calculate(messages);
// Assert
Assert.Equal(1, metrics.MessageCount);
Assert.Equal(1, metrics.ToolCallCount);
}
@@ -181,6 +211,7 @@ public class DefaultChatHistoryMetricsCalculatorTests
[Fact]
public void Calculate_PopulatesGroupIndex()
{
// Arrange
DefaultChatHistoryMetricsCalculator calculator = new();
List<ChatMessage> messages =
[
@@ -189,8 +220,10 @@ public class DefaultChatHistoryMetricsCalculatorTests
new(ChatRole.Assistant, "Hi there"),
];
CompactionMetric metrics = calculator.Calculate(messages);
// Act
ChatHistoryMetric metrics = calculator.Calculate(messages);
// Assert
Assert.Equal(3, metrics.Groups.Count);
Assert.Equal(ChatMessageGroupKind.System, metrics.Groups[0].Kind);
Assert.Equal(ChatMessageGroupKind.UserTurn, metrics.Groups[1].Kind);
@@ -200,23 +233,30 @@ public class DefaultChatHistoryMetricsCalculatorTests
[Fact]
public void EmptyList_GroupIndexIsEmpty()
{
// Arrange
DefaultChatHistoryMetricsCalculator calculator = new();
CompactionMetric metrics = calculator.Calculate([]);
// Act
ChatHistoryMetric metrics = calculator.Calculate([]);
// Assert
Assert.Empty(metrics.Groups);
}
[Fact]
public void GroupIndex_SystemMessage_IdentifiedCorrectly()
{
// Arrange
DefaultChatHistoryMetricsCalculator calculator = new();
List<ChatMessage> messages =
[
new(ChatRole.System, "You are a helpful assistant"),
];
// Act
IReadOnlyList<ChatMessageGroup> groups = calculator.Calculate(messages).Groups;
// Assert
Assert.Single(groups);
Assert.Equal(ChatMessageGroupKind.System, groups[0].Kind);
Assert.Equal(0, groups[0].StartIndex);
@@ -226,6 +266,7 @@ public class DefaultChatHistoryMetricsCalculatorTests
[Fact]
public void GroupIndex_AssistantWithToolCalls_GroupedWithResults()
{
// Arrange
DefaultChatHistoryMetricsCalculator calculator = new();
ChatMessage assistantMsg = new(ChatRole.Assistant, [
new FunctionCallContent("call1", "get_weather", new Dictionary<string, object?> { ["city"] = "NYC" }),
@@ -240,18 +281,21 @@ public class DefaultChatHistoryMetricsCalculatorTests
toolResult,
];
// Act
IReadOnlyList<ChatMessageGroup> groups = calculator.Calculate(messages).Groups;
// Assert
Assert.Equal(2, groups.Count);
Assert.Equal(ChatMessageGroupKind.UserTurn, groups[0].Kind);
Assert.Equal(ChatMessageGroupKind.AssistantToolGroup, groups[1].Kind);
Assert.Equal(1, groups[1].StartIndex);
Assert.Equal(2, groups[1].Count); // assistant + tool result
Assert.Equal(2, groups[1].Count);
}
[Fact]
public void GroupIndex_MultipleToolResults_GroupedTogether()
{
// Arrange
DefaultChatHistoryMetricsCalculator calculator = new();
ChatMessage assistantMsg = new(ChatRole.Assistant, [
new FunctionCallContent("c1", "func1"),
@@ -261,8 +305,10 @@ public class DefaultChatHistoryMetricsCalculatorTests
ChatMessage tool2 = new(ChatRole.Tool, [new FunctionResultContent("c2", "result2")]);
List<ChatMessage> messages = [assistantMsg, tool1, tool2];
// Act
IReadOnlyList<ChatMessageGroup> groups = calculator.Calculate(messages).Groups;
// Assert
Assert.Single(groups);
Assert.Equal(ChatMessageGroupKind.AssistantToolGroup, groups[0].Kind);
Assert.Equal(3, groups[0].Count);
@@ -271,6 +317,7 @@ public class DefaultChatHistoryMetricsCalculatorTests
[Fact]
public void GroupIndex_ComplexConversation_CorrectGrouping()
{
// Arrange
DefaultChatHistoryMetricsCalculator calculator = new();
List<ChatMessage> messages =
[
@@ -283,29 +330,34 @@ public class DefaultChatHistoryMetricsCalculatorTests
new(ChatRole.Assistant, "It's sunny!"),
];
// Act
IReadOnlyList<ChatMessageGroup> groups = calculator.Calculate(messages).Groups;
// Assert
Assert.Equal(6, groups.Count);
Assert.Equal(ChatMessageGroupKind.System, groups[0].Kind);
Assert.Equal(ChatMessageGroupKind.UserTurn, groups[1].Kind);
Assert.Equal(ChatMessageGroupKind.AssistantPlain, groups[2].Kind);
Assert.Equal(ChatMessageGroupKind.UserTurn, groups[3].Kind);
Assert.Equal(ChatMessageGroupKind.AssistantToolGroup, groups[4].Kind);
Assert.Equal(2, groups[4].Count); // assistant + tool
Assert.Equal(2, groups[4].Count);
Assert.Equal(ChatMessageGroupKind.AssistantPlain, groups[5].Kind);
}
[Fact]
public void GroupIndex_OrphanToolResult_IdentifiedCorrectly()
{
// Arrange
DefaultChatHistoryMetricsCalculator calculator = new();
List<ChatMessage> messages =
[
new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c1", "orphan result")]),
];
// Act
IReadOnlyList<ChatMessageGroup> groups = calculator.Calculate(messages).Groups;
// Assert
Assert.Single(groups);
Assert.Equal(ChatMessageGroupKind.ToolResult, groups[0].Kind);
}
@@ -313,14 +365,17 @@ public class DefaultChatHistoryMetricsCalculatorTests
[Fact]
public void GroupIndex_UnknownRole_IdentifiedAsOther()
{
// Arrange
DefaultChatHistoryMetricsCalculator calculator = new();
List<ChatMessage> messages =
[
new(new ChatRole("custom"), "custom message"),
];
// Act
IReadOnlyList<ChatMessageGroup> groups = calculator.Calculate(messages).Groups;
// Assert
Assert.Single(groups);
Assert.Equal(ChatMessageGroupKind.Other, groups[0].Kind);
}
@@ -328,13 +383,16 @@ public class DefaultChatHistoryMetricsCalculatorTests
[Fact]
public void GroupIndex_AssistantWithNullContents_ClassifiedAsPlain()
{
// Arrange
DefaultChatHistoryMetricsCalculator calculator = new();
ChatMessage msg = new(ChatRole.Assistant, "reply");
msg.Contents = null!;
List<ChatMessage> messages = [msg];
// Act
IReadOnlyList<ChatMessageGroup> groups = calculator.Calculate(messages).Groups;
// Assert
Assert.Single(groups);
Assert.Equal(ChatMessageGroupKind.AssistantPlain, groups[0].Kind);
}
@@ -16,7 +16,8 @@ internal sealed class NeverCompactStrategy : ChatHistoryCompactionStrategy
}
public override string Name => "NeverCompact";
public override bool ShouldCompact(CompactionMetric metrics) => false;
protected override bool ShouldCompact(ChatHistoryMetric metrics) => false;
private sealed class NoOpReducer : IChatReducer
{
@@ -1,40 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections;
using System.Collections.Generic;
using System.Linq;
namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction.Internal;
/// <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();
}
@@ -18,7 +18,7 @@ internal sealed class RemoveFirstMessageStrategy : ChatHistoryCompactionStrategy
public override string Name => "RemoveFirst";
public override bool ShouldCompact(CompactionMetric metrics) => metrics.MessageCount > 0;
protected override bool ShouldCompact(ChatHistoryMetric metrics) => metrics.MessageCount > 0;
private sealed class RemoveFirstReducer : IChatReducer
{
@@ -9,10 +9,12 @@ public class MessageGroupTests
[Fact]
public void Equality_Works()
{
// Arrange
ChatMessageGroup a = new(0, 2, ChatMessageGroupKind.AssistantToolGroup);
ChatMessageGroup b = new(0, 2, ChatMessageGroupKind.AssistantToolGroup);
ChatMessageGroup c = new(1, 2, ChatMessageGroupKind.AssistantToolGroup);
// Act & Assert
Assert.Equal(a, b);
Assert.True(a == b);
Assert.NotEqual(a, c);
@@ -22,34 +24,42 @@ public class MessageGroupTests
[Fact]
public void Equals_Object_NullReturnsFalse()
{
// Arrange
ChatMessageGroup group = new(0, 1, ChatMessageGroupKind.System);
// Act & Assert
Assert.False(group.Equals(null));
}
[Fact]
public void Equals_Object_BoxedMessageGroupReturnsTrue()
{
// Arrange
ChatMessageGroup group = new(0, 2, ChatMessageGroupKind.AssistantToolGroup);
object boxed = new ChatMessageGroup(0, 2, ChatMessageGroupKind.AssistantToolGroup);
// Act & Assert
Assert.True(group.Equals(boxed));
}
[Fact]
public void Equals_Object_WrongTypeReturnsFalse()
{
// Arrange
ChatMessageGroup group = new(0, 1, ChatMessageGroupKind.System);
// Act & Assert
Assert.False(group.Equals("not a MessageGroup"));
}
[Fact]
public void GetHashCode_ConsistentForEqualInstances()
{
// Arrange
ChatMessageGroup a = new(0, 2, ChatMessageGroupKind.AssistantToolGroup);
ChatMessageGroup b = new(0, 2, ChatMessageGroupKind.AssistantToolGroup);
// Act & Assert
Assert.Equal(a.GetHashCode(), b.GetHashCode());
}
}
@@ -1,4 +1,4 @@
// Copyright (c) Microsoft. All rights reserved.
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Threading.Tasks;
@@ -7,47 +7,27 @@ using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction;
public class SlidingWindowCompactionStrategyTests
public class SlidingWindowCompactionStrategyTests : CompactionStrategyTestBase
{
[Fact]
public void ShouldCompact_UnderLimit_ReturnsFalse()
{
SlidingWindowCompactionStrategy strategy = new(maxTurns: 10);
CompactionMetric metrics = new() { UserTurnCount = 3 };
Assert.False(strategy.ShouldCompact(metrics));
}
[Fact]
public void ShouldCompact_OverLimit_ReturnsTrue()
{
SlidingWindowCompactionStrategy strategy = new(maxTurns: 2);
CompactionMetric metrics = new() { UserTurnCount = 5 };
Assert.True(strategy.ShouldCompact(metrics));
}
[Fact]
public async Task UnderLimit_NoChangeAsync()
{
SlidingWindowCompactionStrategy strategy = new(maxTurns: 10);
// Arrange
List<ChatMessage> messages =
[
new(ChatRole.User, "Hello"),
new(ChatRole.Assistant, "Hi"),
];
DefaultChatHistoryMetricsCalculator calculator = new();
SlidingWindowCompactionStrategy strategy = new(maxTurns: 10);
CompactionResult result = await strategy.CompactAsync(messages, calculator);
Assert.False(result.Applied);
Assert.Equal(2, messages.Count);
// Act & Assert
await RunCompactionStrategySkippedAsync(strategy, messages);
}
[Fact]
public async Task KeepsLastNTurnsAsync()
{
SlidingWindowCompactionStrategy strategy = new(maxTurns: 2);
// Arrange
List<ChatMessage> messages =
[
new(ChatRole.User, "Turn 1"),
@@ -57,12 +37,12 @@ public class SlidingWindowCompactionStrategyTests
new(ChatRole.User, "Turn 3"),
new(ChatRole.Assistant, "Reply 3"),
];
DefaultChatHistoryMetricsCalculator calculator = new();
SlidingWindowCompactionStrategy strategy = new(maxTurns: 2);
CompactionResult result = await strategy.CompactAsync(messages, calculator);
// Act & Assert
await RunCompactionStrategyReducedAsync(strategy, messages, expectedCount: 4);
Assert.True(result.Applied);
Assert.Equal(4, messages.Count);
// Assert
Assert.Equal("Turn 2", messages[0].Text);
Assert.Equal("Reply 2", messages[1].Text);
Assert.Equal("Turn 3", messages[2].Text);
@@ -72,7 +52,7 @@ public class SlidingWindowCompactionStrategyTests
[Fact]
public async Task PreservesSystemMessagesAsync()
{
SlidingWindowCompactionStrategy strategy = new(maxTurns: 1);
// Arrange
List<ChatMessage> messages =
[
new(ChatRole.System, "You are a helper"),
@@ -81,12 +61,12 @@ public class SlidingWindowCompactionStrategyTests
new(ChatRole.User, "Turn 2"),
new(ChatRole.Assistant, "Reply 2"),
];
DefaultChatHistoryMetricsCalculator calculator = new();
SlidingWindowCompactionStrategy strategy = new(maxTurns: 1);
CompactionResult result = await strategy.CompactAsync(messages, calculator);
// Act & Assert
await RunCompactionStrategyReducedAsync(strategy, messages, expectedCount: 3);
Assert.True(result.Applied);
Assert.Equal(3, messages.Count);
// Assert
Assert.Equal(ChatRole.System, messages[0].Role);
Assert.Equal("You are a helper", messages[0].Text);
Assert.Equal("Turn 2", messages[1].Text);
@@ -96,7 +76,7 @@ public class SlidingWindowCompactionStrategyTests
[Fact]
public async Task PreservesToolGroupsWithinKeptTurnsAsync()
{
SlidingWindowCompactionStrategy strategy = new(maxTurns: 1);
// Arrange
List<ChatMessage> messages =
[
new(ChatRole.User, "Turn 1"),
@@ -106,37 +86,34 @@ public class SlidingWindowCompactionStrategyTests
new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c1", "Sunny")]),
new(ChatRole.Assistant, "It's sunny!"),
];
DefaultChatHistoryMetricsCalculator calculator = new();
SlidingWindowCompactionStrategy strategy = new(maxTurns: 1);
CompactionResult result = await strategy.CompactAsync(messages, calculator);
// Act & Assert
await RunCompactionStrategyReducedAsync(strategy, messages, expectedCount: 4);
Assert.True(result.Applied);
// Turn 1 dropped, Turn 2 kept (user + assistant-tool-group + plain assistant)
Assert.Equal(4, messages.Count);
// Assert
Assert.Equal("Get weather", messages[0].Text);
}
[Fact]
public async Task SingleTurn_AtLimit_NoChangeAsync()
{
SlidingWindowCompactionStrategy strategy = new(maxTurns: 1);
// Arrange
List<ChatMessage> messages =
[
new(ChatRole.User, "Hello"),
new(ChatRole.Assistant, "Hi"),
];
DefaultChatHistoryMetricsCalculator calculator = new();
SlidingWindowCompactionStrategy strategy = new(maxTurns: 1);
CompactionResult result = await strategy.CompactAsync(messages, calculator);
Assert.False(result.Applied);
Assert.Equal(2, messages.Count);
// Act & Assert
await RunCompactionStrategySkippedAsync(strategy, messages);
}
[Fact]
public async Task DropsResponseGroupsFromOldTurnsAsync()
{
SlidingWindowCompactionStrategy strategy = new(maxTurns: 1);
// Arrange
List<ChatMessage> messages =
[
new(ChatRole.User, "Turn 1"),
@@ -146,12 +123,12 @@ public class SlidingWindowCompactionStrategyTests
new(ChatRole.User, "Turn 2"),
new(ChatRole.Assistant, "Reply 2"),
];
DefaultChatHistoryMetricsCalculator calculator = new();
SlidingWindowCompactionStrategy strategy = new(maxTurns: 1);
CompactionResult result = await strategy.CompactAsync(messages, calculator);
// Act & Assert
await RunCompactionStrategyReducedAsync(strategy, messages, expectedCount: 2);
Assert.True(result.Applied);
Assert.Equal(2, messages.Count);
// Assert
Assert.Equal("Turn 2", messages[0].Text);
Assert.Equal("Reply 2", messages[1].Text);
}
@@ -9,44 +9,24 @@ using Moq;
namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction;
public class SummarizationCompactionStrategyTests
public class SummarizationCompactionStrategyTests : CompactionStrategyTestBase
{
[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);
// Arrange
List<ChatMessage> messages =
[
new(ChatRole.User, "Hello"),
new(ChatRole.Assistant, "Hi"),
];
DefaultChatHistoryMetricsCalculator calculator = new();
Mock<IChatClient> chatClientMock = new();
SummarizationCompactionStrategy strategy = new(chatClientMock.Object, maxTokens: 100000);
CompactionResult result = await strategy.CompactAsync(messages, calculator);
// Act & Assert
await RunCompactionStrategySkippedAsync(strategy, messages);
Assert.False(result.Applied);
Assert.Equal(2, messages.Count);
// Assert
chatClientMock.Verify(
c => c.GetResponseAsync(It.IsAny<IEnumerable<ChatMessage>>(), It.IsAny<ChatOptions>(), It.IsAny<CancellationToken>()),
Times.Never);
@@ -55,13 +35,7 @@ public class SummarizationCompactionStrategyTests
[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);
// Arrange
List<ChatMessage> messages =
[
new(ChatRole.User, "What's the weather?"),
@@ -71,14 +45,16 @@ public class SummarizationCompactionStrategyTests
new(ChatRole.User, "Thanks!"),
new(ChatRole.Assistant, "You're welcome!"),
];
DefaultChatHistoryMetricsCalculator calculator = new();
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.")));
SummarizationCompactionStrategy strategy = new(chatClientMock.Object, maxTokens: 1, preserveRecentGroups: 2);
CompactionResult result = await strategy.CompactAsync(messages, calculator);
// Act & Assert
await RunCompactionStrategyReducedAsync(strategy, messages, expectedCount: 3);
Assert.True(result.Applied);
// 6 groups (3 user + 3 assistant), protect last 2 → summarize first 4 groups
// Result: summary + 2 protected groups = 3 messages
Assert.Equal(3, messages.Count);
// Assert
Assert.Contains("[Summary]", messages[0].Text);
Assert.Contains("sunny", messages[0].Text);
Assert.Equal("Thanks!", messages[1].Text);
@@ -88,12 +64,7 @@ public class SummarizationCompactionStrategyTests
[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);
// Arrange
List<ChatMessage> messages =
[
new(ChatRole.System, "You are a helper"),
@@ -102,11 +73,16 @@ public class SummarizationCompactionStrategyTests
new(ChatRole.User, "Turn 2"),
new(ChatRole.Assistant, "Reply 2"),
];
DefaultChatHistoryMetricsCalculator calculator = new();
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);
CompactionResult result = await strategy.CompactAsync(messages, calculator);
// Act & Assert
await RunCompactionStrategyReducedAsync(strategy, messages, expectedCount: 3);
Assert.True(result.Applied);
// Assert
Assert.Equal(ChatRole.System, messages[0].Role);
Assert.Equal("You are a helper", messages[0].Text);
Assert.Contains("[Summary]", messages[1].Text);
@@ -115,20 +91,19 @@ public class SummarizationCompactionStrategyTests
[Fact]
public async Task AllGroupsProtected_NoChangeAsync()
{
Mock<IChatClient> chatClientMock = new();
SummarizationCompactionStrategy strategy = new(chatClientMock.Object, maxTokens: 1, preserveRecentGroups: 10);
// Arrange
List<ChatMessage> messages =
[
new(ChatRole.User, "Hello"),
new(ChatRole.Assistant, "Hi"),
];
DefaultChatHistoryMetricsCalculator calculator = new();
Mock<IChatClient> chatClientMock = new();
SummarizationCompactionStrategy strategy = new(chatClientMock.Object, maxTokens: 1, preserveRecentGroups: 10);
CompactionResult result = await strategy.CompactAsync(messages, calculator);
// Act & Assert
await RunCompactionStrategySkippedAsync(strategy, messages);
// All groups protected → nothing to summarize → no change
Assert.False(result.Applied);
Assert.Equal(2, messages.Count);
// Assert
chatClientMock.Verify(
c => c.GetResponseAsync(It.IsAny<IEnumerable<ChatMessage>>(), It.IsAny<ChatOptions>(), It.IsAny<CancellationToken>()),
Times.Never);
@@ -137,16 +112,8 @@ public class SummarizationCompactionStrategyTests
[Fact]
public async Task CustomPrompt_UsedInRequestAsync()
{
// Arrange
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"),
@@ -154,12 +121,19 @@ public class SummarizationCompactionStrategyTests
new(ChatRole.User, "Second"),
new(ChatRole.Assistant, "Reply 2"),
];
DefaultChatHistoryMetricsCalculator calculator = new();
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);
await strategy.CompactAsync(messages, calculator);
// Act & Assert
await RunCompactionStrategyReducedAsync(strategy, messages, expectedCount: 2);
// Assert
Assert.NotNull(capturedMessages);
// First message in request should be the custom system prompt
Assert.Equal(ChatRole.System, capturedMessages![0].Role);
Assert.Equal(CustomPrompt, capturedMessages[0].Text);
}
@@ -167,12 +141,7 @@ public class SummarizationCompactionStrategyTests
[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);
// Arrange
List<ChatMessage> messages =
[
new(ChatRole.User, "First"),
@@ -180,11 +149,16 @@ public class SummarizationCompactionStrategyTests
new(ChatRole.User, "Second"),
new(ChatRole.Assistant, "Reply 2"),
];
DefaultChatHistoryMetricsCalculator calculator = new();
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);
CompactionResult result = await strategy.CompactAsync(messages, calculator);
// Act & Assert
await RunCompactionStrategyReducedAsync(strategy, messages, expectedCount: 2);
Assert.True(result.Applied);
// Assert
Assert.Contains("[Summary unavailable]", messages[0].Text);
}
}
@@ -1,4 +1,4 @@
// Copyright (c) Microsoft. All rights reserved.
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Threading.Tasks;
@@ -7,57 +7,28 @@ using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction;
public class ToolResultCompactionStrategyTests
public class ToolResultCompactionStrategyTests : CompactionStrategyTestBase
{
[Fact]
public void ShouldCompact_UnderLimit_ReturnsFalse()
{
ToolResultCompactionStrategy strategy = new(maxTokens: 100000);
CompactionMetric metrics = new() { TokenCount = 500, ToolCallCount = 3 };
Assert.False(strategy.ShouldCompact(metrics));
}
[Fact]
public void ShouldCompact_OverLimitNoToolCalls_ReturnsFalse()
{
ToolResultCompactionStrategy strategy = new(maxTokens: 100);
CompactionMetric metrics = new() { TokenCount = 500, ToolCallCount = 0 };
Assert.False(strategy.ShouldCompact(metrics));
}
[Fact]
public void ShouldCompact_OverLimitWithToolCalls_ReturnsTrue()
{
ToolResultCompactionStrategy strategy = new(maxTokens: 100);
CompactionMetric metrics = new() { TokenCount = 500, ToolCallCount = 2 };
Assert.True(strategy.ShouldCompact(metrics));
}
[Fact]
public async Task UnderLimit_NoChangeAsync()
{
ToolResultCompactionStrategy strategy = new(maxTokens: 100000);
// Arrange
List<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();
ToolResultCompactionStrategy strategy = new(maxTokens: 100000);
CompactionResult result = await strategy.CompactAsync(messages, calculator);
Assert.False(result.Applied);
Assert.Equal(3, messages.Count);
// Act & Assert
await RunCompactionStrategySkippedAsync(strategy, messages);
}
[Fact]
public async Task CollapsesOldToolGroupAsync()
{
ToolResultCompactionStrategy strategy = new(maxTokens: 1, preserveRecentGroups: 1);
// Arrange
List<ChatMessage> messages =
[
new(ChatRole.User, "Check weather"),
@@ -66,13 +37,12 @@ public class ToolResultCompactionStrategyTests
new(ChatRole.User, "Thanks"),
new(ChatRole.Assistant, "You're welcome!"),
];
DefaultChatHistoryMetricsCalculator calculator = new();
ToolResultCompactionStrategy strategy = new(maxTokens: 1, preserveRecentGroups: 1);
CompactionResult result = await strategy.CompactAsync(messages, calculator);
// Act & Assert
await RunCompactionStrategyReducedAsync(strategy, messages, expectedCount: 4);
Assert.True(result.Applied);
// The old tool group (assistant+tool = 2 messages) should be collapsed to 1
Assert.Equal(4, messages.Count); // user + [collapsed] + user + assistant
// Assert
Assert.Contains("[Tool calls: get_weather]", messages[1].Text);
Assert.Equal(ChatRole.Assistant, messages[1].Role);
}
@@ -80,8 +50,7 @@ public class ToolResultCompactionStrategyTests
[Fact]
public async Task ProtectsRecentGroupsAsync()
{
// With preserveRecentGroups=4, all groups are protected (5 groups, protect 4 non-system)
ToolResultCompactionStrategy strategy = new(maxTokens: 1, preserveRecentGroups: 10);
// Arrange
List<ChatMessage> messages =
[
new(ChatRole.User, "Check weather"),
@@ -90,19 +59,16 @@ public class ToolResultCompactionStrategyTests
new(ChatRole.User, "Thanks"),
new(ChatRole.Assistant, "You're welcome!"),
];
DefaultChatHistoryMetricsCalculator calculator = new();
ToolResultCompactionStrategy strategy = new(maxTokens: 1, preserveRecentGroups: 10);
CompactionResult result = await strategy.CompactAsync(messages, calculator);
// All groups protected, so no collapse
Assert.False(result.Applied);
Assert.Equal(5, messages.Count);
// Act & Assert
await RunCompactionStrategySkippedAsync(strategy, messages);
}
[Fact]
public async Task PreservesSystemMessagesAsync()
{
ToolResultCompactionStrategy strategy = new(maxTokens: 1, preserveRecentGroups: 1);
// Arrange
List<ChatMessage> messages =
[
new(ChatRole.System, "You are a helper"),
@@ -112,11 +78,12 @@ public class ToolResultCompactionStrategyTests
new(ChatRole.User, "Thanks"),
new(ChatRole.Assistant, "You're welcome!"),
];
DefaultChatHistoryMetricsCalculator calculator = new();
ToolResultCompactionStrategy strategy = new(maxTokens: 1, preserveRecentGroups: 1);
CompactionResult result = await strategy.CompactAsync(messages, calculator);
// Act & Assert
await RunCompactionStrategyReducedAsync(strategy, messages, expectedCount: 5);
Assert.True(result.Applied);
// Assert
Assert.Equal(ChatRole.System, messages[0].Role);
Assert.Equal("You are a helper", messages[0].Text);
}
@@ -124,7 +91,7 @@ public class ToolResultCompactionStrategyTests
[Fact]
public async Task MultipleToolCalls_ListedInSummaryAsync()
{
ToolResultCompactionStrategy strategy = new(maxTokens: 1, preserveRecentGroups: 1);
// Arrange
List<ChatMessage> messages =
[
new(ChatRole.User, "Do research"),
@@ -137,13 +104,12 @@ public class ToolResultCompactionStrategyTests
new(ChatRole.User, "Summarize"),
new(ChatRole.Assistant, "Here's the summary."),
];
DefaultChatHistoryMetricsCalculator calculator = new();
ToolResultCompactionStrategy strategy = new(maxTokens: 1, preserveRecentGroups: 1);
CompactionResult result = await strategy.CompactAsync(messages, calculator);
// Act & Assert
await RunCompactionStrategyReducedAsync(strategy, messages, expectedCount: 4);
Assert.True(result.Applied);
// Old tool group (1 assistant + 2 tools = 3 messages) collapsed to 1
Assert.Equal(4, messages.Count);
// Assert
Assert.Contains("search", messages[1].Text);
Assert.Contains("fetch_page", messages[1].Text);
}
@@ -151,18 +117,15 @@ public class ToolResultCompactionStrategyTests
[Fact]
public async Task NoToolGroups_NoChangeAsync()
{
ToolResultCompactionStrategy strategy = new(maxTokens: 1);
// Arrange
List<ChatMessage> messages =
[
new(ChatRole.User, "Hello"),
new(ChatRole.Assistant, "Hi there"),
];
DefaultChatHistoryMetricsCalculator calculator = new();
ToolResultCompactionStrategy strategy = new(maxTokens: 1);
// ToolCallCount is 0, so ShouldCompact returns false
CompactionResult result = await strategy.CompactAsync(messages, calculator);
Assert.False(result.Applied);
Assert.Equal(2, messages.Count);
// Act & Assert
await RunCompactionStrategySkippedAsync(strategy, messages);
}
}
@@ -1,4 +1,4 @@
// Copyright (c) Microsoft. All rights reserved.
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Threading.Tasks;
@@ -7,29 +7,26 @@ using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction;
public class TruncationCompactionStrategyTests
public class TruncationCompactionStrategyTests : CompactionStrategyTestBase
{
[Fact]
public async Task UnderLimit_NoChangeAsync()
{
TruncationCompactionStrategy strategy = new(maxTokens: 100000);
// Arrange
List<ChatMessage> messages =
[
new(ChatRole.User, "Hello"),
];
DefaultChatHistoryMetricsCalculator calculator = new();
TruncationCompactionStrategy strategy = new(maxTokens: 100000);
CompactionResult result = await strategy.CompactAsync(messages, calculator);
Assert.False(result.Applied);
Assert.Single(messages);
// Act & Assert
await RunCompactionStrategySkippedAsync(strategy, messages);
}
[Fact]
public async Task OverLimit_RemovesOldestGroupsAsync()
{
// Use a very low max to trigger compaction
TruncationCompactionStrategy strategy = new(maxTokens: 1);
// Arrange
List<ChatMessage> messages =
[
new(ChatRole.User, "First message"),
@@ -37,77 +34,45 @@ public class TruncationCompactionStrategyTests
new(ChatRole.User, "Second message"),
new(ChatRole.Assistant, "Second reply"),
];
DefaultChatHistoryMetricsCalculator calculator = new();
TruncationCompactionStrategy strategy = new(maxTokens: 1);
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));
// Act & Assert
await RunCompactionStrategyReducedAsync(strategy, messages, expectedCount: 1);
}
[Fact]
public async Task SystemOnlyMessages_NoChangeAsync()
{
// Only system messages → removableGroups.Count == 0 → no change
TruncationCompactionStrategy strategy = new(maxTokens: 1);
// Arrange
List<ChatMessage> messages =
[
new(ChatRole.System, "You are a helper"),
];
DefaultChatHistoryMetricsCalculator calculator = new();
TruncationCompactionStrategy strategy = new(maxTokens: 1);
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);
// Act & Assert
await RunCompactionStrategySkippedAsync(strategy, messages);
}
[Fact]
public async Task SingleNonSystemGroup_NoChangeAsync()
{
// Only one non-system group → maxRemovable <= 0 → no change
TruncationCompactionStrategy strategy = new(maxTokens: 1);
// Arrange
List<ChatMessage> messages =
[
new(ChatRole.System, "System prompt"),
new(ChatRole.User, "Only user message"),
];
DefaultChatHistoryMetricsCalculator calculator = new();
TruncationCompactionStrategy strategy = new(maxTokens: 1);
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);
// Act & Assert
await RunCompactionStrategySkippedAsync(strategy, messages);
}
[Fact]
public async Task PreserveRecentGroups_KeepsMultipleGroupsAsync()
{
// preserveRecentGroups=2 means keep at least the last 2 non-system groups
TruncationCompactionStrategy strategy = new(maxTokens: 1, preserveRecentGroups: 2);
// Arrange
List<ChatMessage> messages =
[
new(ChatRole.User, "Turn 1"),
@@ -117,14 +82,12 @@ public class TruncationCompactionStrategyTests
new(ChatRole.User, "Turn 3"),
new(ChatRole.Assistant, "Reply 3"),
];
DefaultChatHistoryMetricsCalculator calculator = new();
TruncationCompactionStrategy strategy = new(maxTokens: 1, preserveRecentGroups: 2);
CompactionResult result = await strategy.CompactAsync(messages, calculator);
// Act & Assert
await RunCompactionStrategyReducedAsync(strategy, messages, expectedCount: 2);
Assert.True(result.Applied);
// 6 groups (3 user + 3 assistant), protect last 2 → 4 removable
// Should keep at least: Turn 3 user + Reply 3 assistant (last 2 groups)
Assert.True(messages.Count >= 2);
// Assert
Assert.Equal("Reply 3", messages[^1].Text);
}
}