This commit is contained in:
Chris Rickman
2026-03-05 01:48:40 -08:00
Unverified
parent fcd60daed5
commit eb8406214e
27 changed files with 1450 additions and 576 deletions
@@ -39,23 +39,18 @@ 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 MaxGroups = 2;
PipelineCompactionStrategy compactionPipeline =
new(// 1. Gentle: collapse old tool-call groups into short summaries like "[Tool calls: LookupPrice]"
//new ToolResultCompactionStrategy(MaxTokens, preserveRecentGroups: 2),
new ToolResultCompactionStrategy(CompactionTriggers.TokensExceed(0x200)),
// 2. Moderate: use an LLM to summarize older conversation spans into a concise message
new SummarizationCompactionStrategy(summarizerChatClient, MaxGroups)
new SummarizationCompactionStrategy(summarizerChatClient, CompactionTriggers.TokensExceed(0x500)),
// 3. Aggressive: keep only the last N user turns and their responses
//new SlidingWindowCompactionStrategy(MaxTurns),
new SlidingWindowCompactionStrategy(maximumTurns: 4),
// 4. Emergency: drop oldest groups until under the token budget
//new TruncationCompactionStrategy(MaxGroups)
);
new TruncationCompactionStrategy(CompactionTriggers.TokensExceed(0x8000)));
// Create the agent with an in-memory chat history provider whose reducer is the compaction pipeline.
AIAgent agent =
@@ -1,38 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Threading;
using System.Threading.Tasks;
namespace Microsoft.Agents.AI.Compaction;
/// <summary>
/// Defines a strategy for compacting a <see cref="MessageIndex"/> to reduce context size.
/// </summary>
/// <remarks>
/// <para>
/// Compaction strategies operate on <see cref="MessageIndex"/> instances, which organize messages
/// into atomic groups that respect the tool-call/result pairing constraint. Strategies mutate the collection
/// in place by marking groups as excluded, removing groups, or replacing message content (e.g., with summaries).
/// </para>
/// <para>
/// Strategies can be applied at three lifecycle points:
/// <list type="bullet">
/// <item><description><b>In-run</b>: During the tool loop, before each LLM call, to keep context within token limits.</description></item>
/// <item><description><b>Pre-write</b>: Before persisting messages to storage via <see cref="ChatHistoryProvider"/>.</description></item>
/// <item><description><b>On existing storage</b>: As a maintenance operation to compact stored history.</description></item>
/// </list>
/// </para>
/// <para>
/// Multiple strategies can be composed by applying them sequentially to the same <see cref="MessageIndex"/>.
/// </para>
/// </remarks>
public interface ICompactionStrategy
{
/// <summary>
/// Compacts the specified message groups in place.
/// </summary>
/// <param name="groups">The message group collection to compact. The strategy mutates this collection in place.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
/// <returns>A task representing the asynchronous operation. The task result is <see langword="true"/> if compaction occurred, <see langword="false"/> otherwise.</returns>
Task<bool> CompactAsync(MessageIndex groups, CancellationToken cancellationToken = default);
}
@@ -1,83 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Compaction;
/// <summary>
/// A compaction strategy that executes a sequential pipeline of <see cref="ICompactionStrategy"/> instances
/// against the same <see cref="MessageIndex"/>.
/// </summary>
/// <remarks>
/// <para>
/// Each strategy in the pipeline operates on the result of the previous one, enabling composed behaviors
/// such as summarizing older messages first and then truncating to fit a token budget.
/// </para>
/// <para>
/// When <see cref="EarlyStop"/> is <see langword="true"/> and a <see cref="TargetIncludedGroupCount"/> is configured,
/// the pipeline stops executing after a strategy reduces the included group count to or below the target.
/// This avoids unnecessary work when an earlier strategy is sufficient.
/// </para>
/// </remarks>
public sealed class PipelineCompactionStrategy : ICompactionStrategy
{
/// <summary>
/// Initializes a new instance of the <see cref="PipelineCompactionStrategy"/> class.
/// </summary>
/// <param name="strategies">The ordered sequence of strategies to execute. Must not be empty.</param>
///// <param name="cache">An optional cache for <see cref="MessageIndex"/> instances. When <see langword="null"/>, a default <see cref="InMemoryMessageIndexCache"/> is created.</param>
public PipelineCompactionStrategy(params IEnumerable<ICompactionStrategy> strategies/*, IMessageIndexCache? cache = null*/)
{
this.Strategies = [.. Throw.IfNull(strategies)];
}
/// <summary>
/// Gets the ordered list of strategies in this pipeline.
/// </summary>
public IReadOnlyList<ICompactionStrategy> Strategies { get; }
/// <summary>
/// Gets or sets a value indicating whether the pipeline should stop executing after a strategy
/// brings the included group count to or below <see cref="TargetIncludedGroupCount"/>.
/// </summary>
/// <value>
/// Defaults to <see langword="false"/>, meaning all strategies are always executed.
/// </value>
public bool EarlyStop { get; set; }
/// <summary>
/// Gets or sets the target number of included groups at which the pipeline stops
/// when <see cref="EarlyStop"/> is <see langword="true"/>.
/// </summary>
/// <value>
/// Defaults to <see langword="null"/>, meaning early stop checks are not performed
/// even when <see cref="EarlyStop"/> is <see langword="true"/>.
/// </value>
public int? TargetIncludedGroupCount { get; set; }
/// <inheritdoc/>
public async Task<bool> CompactAsync(MessageIndex groups, CancellationToken cancellationToken = default)
{
bool anyCompacted = false;
foreach (ICompactionStrategy strategy in this.Strategies)
{
bool compacted = await strategy.CompactAsync(groups, cancellationToken).ConfigureAwait(false);
if (compacted)
{
anyCompacted = true;
}
if (this.EarlyStop && this.TargetIncludedGroupCount is int targetIncludedGroupCount && groups.IncludedGroupCount <= targetIncludedGroupCount)
{
break;
}
}
return anyCompacted;
}
}
@@ -3,7 +3,6 @@
using System;
using System.Collections.Generic;
using System.Text.Json;
using Microsoft.Agents.AI.Compaction;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI;
@@ -29,7 +29,6 @@
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.AI.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.ML.Tokenizers" />
</ItemGroup>
<ItemGroup>
@@ -1,4 +1,4 @@
// Copyright (c) Microsoft. All rights reserved.
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using Microsoft.Agents.AI.Compaction;
@@ -47,7 +47,7 @@ public sealed class ChatClientAgentOptions
public IEnumerable<AIContextProvider>? AIContextProviders { get; set; }
/// <summary>
/// Gets or sets the <see cref="ICompactionStrategy"/> to use for in-run context compaction.
/// Gets or sets the <see cref="CompactionStrategy"/> to use for in-run context compaction.
/// </summary>
/// <remarks>
/// <para>
@@ -57,10 +57,10 @@ public sealed class ChatClientAgentOptions
/// </para>
/// <para>
/// The strategy organizes messages into atomic groups (preserving tool-call/result pairings)
/// before applying compaction logic. See <see cref="ICompactionStrategy"/> for details.
/// before applying compaction logic. See <see cref="CompactionStrategy"/> for details.
/// </para>
/// </remarks>
public ICompactionStrategy? CompactionStrategy { get; set; }
public CompactionStrategy? CompactionStrategy { get; set; }
/// <summary>
/// Gets or sets a value indicating whether to use the provided <see cref="IChatClient"/> instance as is,
@@ -2,6 +2,7 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Runtime.CompilerServices;
using System.Text.Json.Serialization;
using System.Threading;
@@ -12,7 +13,7 @@ using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Compaction;
/// <summary>
/// A delegating <see cref="IChatClient"/> that applies an <see cref="ICompactionStrategy"/> to the message list
/// A delegating <see cref="IChatClient"/> that applies an <see cref="CompactionStrategy"/> to the message list
/// before each call to the inner chat client.
/// </summary>
/// <remarks>
@@ -28,7 +29,7 @@ namespace Microsoft.Agents.AI.Compaction;
/// </remarks>
internal sealed class CompactingChatClient : DelegatingChatClient
{
private readonly ICompactionStrategy _compactionStrategy;
private readonly CompactionStrategy _compactionStrategy;
private readonly ProviderSessionState<State> _sessionState;
/// <summary>
@@ -36,7 +37,7 @@ internal sealed class CompactingChatClient : DelegatingChatClient
/// </summary>
/// <param name="innerClient">The inner chat client to delegate to.</param>
/// <param name="compactionStrategy">The compaction strategy to apply before each call.</param>
public CompactingChatClient(IChatClient innerClient, ICompactionStrategy compactionStrategy)
public CompactingChatClient(IChatClient innerClient, CompactionStrategy compactionStrategy)
: base(innerClient)
{
this._compactionStrategy = Throw.IfNull(compactionStrategy);
@@ -75,7 +76,7 @@ internal sealed class CompactingChatClient : DelegatingChatClient
Throw.IfNull(serviceType);
return
serviceKey is null && serviceType.IsInstanceOfType(typeof(ICompactionStrategy)) ?
serviceKey is null && serviceType.IsInstanceOfType(typeof(CompactionStrategy)) ?
this._compactionStrategy :
base.GetService(serviceType, serviceKey);
}
@@ -108,8 +109,12 @@ internal sealed class CompactingChatClient : DelegatingChatClient
messageIndex = MessageIndex.Create(messageList);
}
// Apply compaction
// Apply compaction
Stopwatch stopwatch = Stopwatch.StartNew();
bool wasCompacted = await this._compactionStrategy.CompactAsync(messageIndex, cancellationToken).ConfigureAwait(false);
stopwatch.Stop();
Debug.WriteLine($"COMPACTION: {wasCompacted} - {stopwatch.ElapsedMilliseconds}ms");
if (wasCompacted)
{
@@ -0,0 +1,107 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Compaction;
/// <summary>
/// Base class for strategies that compact a <see cref="MessageIndex"/> to reduce context size.
/// </summary>
/// <remarks>
/// <para>
/// Compaction strategies operate on <see cref="MessageIndex"/> instances, which organize messages
/// into atomic groups that respect the tool-call/result pairing constraint. Strategies mutate the collection
/// in place by marking groups as excluded, removing groups, or replacing message content (e.g., with summaries).
/// </para>
/// <para>
/// Every strategy requires a <see cref="CompactionTrigger"/> that determines whether compaction should
/// proceed based on current <see cref="MessageIndex"/> metrics (token count, message count, turn count, etc.).
/// The base class evaluates this trigger at the start of <see cref="CompactAsync"/> and skips compaction when
/// the trigger returns <see langword="false"/>.
/// </para>
/// <para>
/// Strategies can be applied at three lifecycle points:
/// <list type="bullet">
/// <item><description><b>In-run</b>: During the tool loop, before each LLM call, to keep context within token limits.</description></item>
/// <item><description><b>Pre-write</b>: Before persisting messages to storage via <see cref="ChatHistoryProvider"/>.</description></item>
/// <item><description><b>On existing storage</b>: As a maintenance operation to compact stored history.</description></item>
/// </list>
/// </para>
/// <para>
/// Multiple strategies can be composed by applying them sequentially to the same <see cref="MessageIndex"/>
/// via <see cref="PipelineCompactionStrategy"/>.
/// </para>
/// </remarks>
public abstract class CompactionStrategy
{
/// <summary>
/// Initializes a new instance of the <see cref="CompactionStrategy"/> class.
/// </summary>
/// <param name="trigger">
/// The <see cref="CompactionTrigger"/> that determines whether compaction should proceed.
/// </param>
protected CompactionStrategy(CompactionTrigger trigger)
{
this.Trigger = Throw.IfNull(trigger);
}
/// <summary>
/// Gets the trigger predicate that controls when compaction proceeds.
/// </summary>
protected CompactionTrigger Trigger { get; }
/// <summary>
/// Evaluates the <see cref="Trigger"/> and, when it fires, delegates to
/// <see cref="ApplyCompactionAsync"/> and reports compaction metrics.
/// </summary>
/// <param name="index">The message index to compact. The strategy mutates this collection in place.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
/// <returns>A task representing the asynchronous operation. The task result is <see langword="true"/> if compaction occurred, <see langword="false"/> otherwise.</returns>
public async Task<bool> CompactAsync(MessageIndex index, CancellationToken cancellationToken = default)
{
if (!this.Trigger(index))
{
return false;
}
int beforeTokens = index.IncludedTokenCount;
int beforeGroups = index.IncludedGroupCount;
int beforeMessages = index.IncludedMessageCount;
Stopwatch stopwatch = Stopwatch.StartNew();
bool compacted = await this.ApplyCompactionAsync(index, cancellationToken).ConfigureAwait(false);
stopwatch.Stop();
if (compacted)
{
Debug.WriteLine(
$"""
COMPACTION: {this.GetType().Name}
Duration {stopwatch.ElapsedMilliseconds}ms
Messages {beforeMessages} => {index.IncludedMessageCount}
Groups {beforeGroups} => {index.IncludedGroupCount}
Tokens {beforeTokens} => {index.IncludedTokenCount}
""");
}
return compacted;
}
/// <summary>
/// Applies the strategy-specific compaction logic to the specified message index.
/// </summary>
/// <remarks>
/// This method is called by <see cref="CompactAsync"/> only when the <see cref="Trigger"/>
/// returns <see langword="true"/>. Implementations do not need to evaluate the trigger or
/// report metrics — the base class handles both.
/// </remarks>
/// <param name="index">The message index to compact. The strategy mutates this collection in place.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
/// <returns>A task whose result is <see langword="true"/> if any compaction was performed, <see langword="false"/> otherwise.</returns>
protected abstract Task<bool> ApplyCompactionAsync(MessageIndex index, CancellationToken cancellationToken);
}
@@ -0,0 +1,10 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI.Compaction;
/// <summary>
/// A predicate that evaluates whether compaction should proceed based on current <see cref="MessageIndex"/> metrics.
/// </summary>
/// <param name="index">The current message index with group, token, message, and turn metrics.</param>
/// <returns><see langword="true"/> if compaction should proceed; <see langword="false"/> to skip.</returns>
public delegate bool CompactionTrigger(MessageIndex index);
@@ -0,0 +1,100 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Linq;
namespace Microsoft.Agents.AI.Compaction;
/// <summary>
/// Provides factory methods for common <see cref="CompactionTrigger"/> predicates.
/// </summary>
/// <remarks>
/// These triggers evaluate included (non-excluded) metrics from the <see cref="MessageIndex"/>.
/// Combine triggers with <see cref="All"/> or <see cref="Any"/> for compound conditions,
/// or write a custom lambda for full flexibility.
/// </remarks>
public static class CompactionTriggers
{
/// <summary>
/// Always triger compaction, regardless of the message index state.
/// </summary>
public static readonly CompactionTrigger Always =
_ => true;
/// <summary>
/// Creates a trigger that fires when the included token count exceeds the specified maximum.
/// </summary>
/// <param name="maxTokens">The token threshold. Compaction proceeds when included tokens exceed this value.</param>
/// <returns>A <see cref="CompactionTrigger"/> that evaluates included token count.</returns>
public static CompactionTrigger TokensExceed(int maxTokens) =>
index => index.IncludedTokenCount > maxTokens;
/// <summary>
/// Creates a trigger that fires when the included message count exceeds the specified maximum.
/// </summary>
/// <param name="maxMessages">The message threshold. Compaction proceeds when included messages exceed this value.</param>
/// <returns>A <see cref="CompactionTrigger"/> that evaluates included message count.</returns>
public static CompactionTrigger MessagesExceed(int maxMessages) =>
index => index.IncludedMessageCount > maxMessages;
/// <summary>
/// Creates a trigger that fires when the included user turn count exceeds the specified maximum.
/// </summary>
/// <param name="maxTurns">The turn threshold. Compaction proceeds when included turns exceed this value.</param>
/// <returns>A <see cref="CompactionTrigger"/> that evaluates included turn count.</returns>
public static CompactionTrigger TurnsExceed(int maxTurns) =>
index => index.IncludedTurnCount > maxTurns;
/// <summary>
/// Creates a trigger that fires when the included group count exceeds the specified maximum.
/// </summary>
/// <param name="maxGroups">The group threshold. Compaction proceeds when included groups exceed this value.</param>
/// <returns>A <see cref="CompactionTrigger"/> that evaluates included group count.</returns>
public static CompactionTrigger GroupsExceed(int maxGroups) =>
index => index.IncludedGroupCount > maxGroups;
/// <summary>
/// Creates a trigger that fires when the included message index contains at least one
/// non-excluded <see cref="MessageGroupKind.ToolCall"/> group.
/// </summary>
/// <returns>A <see cref="CompactionTrigger"/> that evaluates included tool call presence.</returns>
public static CompactionTrigger HasToolCalls() =>
index => index.Groups.Any(g => !g.IsExcluded && g.Kind == MessageGroupKind.ToolCall);
/// <summary>
/// Creates a compound trigger that fires only when <b>all</b> of the specified triggers fire.
/// </summary>
/// <param name="triggers">The triggers to combine with logical AND.</param>
/// <returns>A <see cref="CompactionTrigger"/> that requires all conditions to be met.</returns>
public static CompactionTrigger All(params CompactionTrigger[] triggers) =>
index =>
{
for (int i = 0; i < triggers.Length; i++)
{
if (!triggers[i](index))
{
return false;
}
}
return true;
};
/// <summary>
/// Creates a compound trigger that fires when <b>any</b> of the specified triggers fire.
/// </summary>
/// <param name="triggers">The triggers to combine with logical OR.</param>
/// <returns>A <see cref="CompactionTrigger"/> that requires at least one condition to be met.</returns>
public static CompactionTrigger Any(params CompactionTrigger[] triggers) =>
index =>
{
for (int i = 0; i < triggers.Length; i++)
{
if (triggers[i](index))
{
return true;
}
}
return false;
};
}
@@ -0,0 +1,59 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Compaction;
/// <summary>
/// A compaction strategy that executes a sequential pipeline of <see cref="CompactionStrategy"/> instances
/// against the same <see cref="MessageIndex"/>.
/// </summary>
/// <remarks>
/// <para>
/// Each strategy in the pipeline operates on the result of the previous one, enabling composed behaviors
/// such as summarizing older messages first and then truncating to fit a token budget.
/// </para>
/// <para>
/// The pipeline's own <see cref="CompactionStrategy.Trigger"/> is evaluated first. If it returns
/// <see langword="false"/>, none of the child strategies are executed. Each child strategy also
/// evaluates its own trigger independently.
/// </para>
/// </remarks>
public sealed class PipelineCompactionStrategy : CompactionStrategy
{
/// <summary>
/// Initializes a new instance of the <see cref="PipelineCompactionStrategy"/> class.
/// </summary>
/// <param name="strategies">The ordered sequence of strategies to execute.</param>
public PipelineCompactionStrategy(params IEnumerable<CompactionStrategy> strategies)
: base(CompactionTriggers.Always)
{
this.Strategies = [.. Throw.IfNull(strategies)];
}
/// <summary>
/// Gets the ordered list of strategies in this pipeline.
/// </summary>
public IReadOnlyList<CompactionStrategy> Strategies { get; }
/// <inheritdoc/>
protected override async Task<bool> ApplyCompactionAsync(MessageIndex index, CancellationToken cancellationToken)
{
bool anyCompacted = false;
foreach (CompactionStrategy strategy in this.Strategies)
{
bool compacted = await strategy.CompactAsync(index, cancellationToken).ConfigureAwait(false);
if (compacted)
{
anyCompacted = true;
}
}
return anyCompacted;
}
}
@@ -0,0 +1,97 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
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 (via <see cref="MessageGroup.TurnIndex"/>) and keeps the last
/// <see cref="MaxTurns"/> turns along with all response groups (assistant replies,
/// tool call groups) that belong to each kept turn.
/// </para>
/// <para>
/// The <see cref="CompactionTrigger"/> predicate controls when compaction proceeds.
/// When <see langword="null"/>, a default trigger of <see cref="CompactionTriggers.TurnsExceed"/>
/// with <see cref="MaxTurns"/> is used.
/// </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 sealed class SlidingWindowCompactionStrategy : CompactionStrategy
{
/// <summary>
/// The default maximum number of user turns to retain before compaction occurs. This default is a reasonable starting point
/// for many conversations, but should be tuned based on the expected conversation length and token budget.
/// </summary>
public const int DefaultMaximumTurns = 32;
/// <summary>
/// Initializes a new instance of the <see cref="SlidingWindowCompactionStrategy"/> class.
/// </summary>
/// <param name="maximumTurns">
/// The maximum number of user turns to keep. Older turns and their associated responses are removed.
/// </param>
public SlidingWindowCompactionStrategy(int maximumTurns = DefaultMaximumTurns)
: base(CompactionTriggers.TurnsExceed(maximumTurns))
{
this.MaxTurns = maximumTurns;
}
/// <summary>
/// Gets the maximum number of user turns to retain after compaction.
/// </summary>
public int MaxTurns { get; }
/// <inheritdoc/>
protected override Task<bool> ApplyCompactionAsync(MessageIndex index, CancellationToken cancellationToken)
{
// Collect distinct included turn indices in order
List<int> includedTurns = [];
foreach (MessageGroup group in index.Groups)
{
if (!group.IsExcluded && group.TurnIndex is int turnIndex && !includedTurns.Contains(turnIndex))
{
includedTurns.Add(turnIndex);
}
}
if (includedTurns.Count <= this.MaxTurns)
{
return Task.FromResult(false);
}
// Determine which turn indices to exclude (oldest)
int turnsToRemove = includedTurns.Count - this.MaxTurns;
HashSet<int> excludedTurnIndices = [.. includedTurns.Take(turnsToRemove)];
bool compacted = false;
for (int i = 0; i < index.Groups.Count; i++)
{
MessageGroup group = index.Groups[i];
if (group.IsExcluded || group.Kind == MessageGroupKind.System)
{
continue;
}
if (group.TurnIndex is int ti && excludedTurnIndices.Contains(ti))
{
group.IsExcluded = true;
group.ExcludeReason = $"Excluded by {nameof(SlidingWindowCompactionStrategy)}";
compacted = true;
}
}
return Task.FromResult(compacted);
}
}
@@ -1,5 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
@@ -9,36 +11,62 @@ using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Compaction;
/// <summary>
/// A compaction strategy that summarizes older message groups using an <see cref="IChatClient"/>,
/// replacing them with a single summary message.
/// A compaction strategy that uses an LLM to summarize older portions of the conversation,
/// replacing them with a single summary message that preserves key facts and context.
/// </summary>
/// <remarks>
/// <para>
/// When the number of included message groups exceeds <see cref="MaxGroupsBeforeSummary"/>,
/// this strategy extracts the oldest non-system groups (up to the threshold), sends them
/// to an <see cref="IChatClient"/> for summarization, and replaces those groups with a single
/// assistant message containing the summary.
/// This strategy protects system messages and the most recent <see cref="PreserveRecentGroups"/>
/// 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
/// with <see cref="MessageGroupKind.Summary"/>.
/// </para>
/// <para>
/// System message groups are always preserved and never included in summarization.
/// The <see cref="CompactionTrigger"/> predicate controls when compaction proceeds.
/// When <see langword="null"/>, the strategy compacts whenever there are groups older than the preserve window.
/// Use <see cref="CompactionTriggers"/> for common trigger conditions such as token thresholds.
/// </para>
/// </remarks>
public sealed class SummarizationCompactionStrategy : ICompactionStrategy
public sealed class SummarizationCompactionStrategy : CompactionStrategy
{
private const string DefaultSummarizationPrompt =
"Summarize the following conversation concisely, preserving key facts, decisions, and context. " +
"Focus on information that would be needed to continue the conversation effectively.";
/// <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 chat client to use for generating summaries.</param>
/// <param name="maxGroupsBeforeSummary">The maximum number of included groups allowed before summarization is triggered.</param>
/// <param name="summarizationPrompt">Optional custom prompt for the summarization request. If <see langword="null"/>, a default prompt is used.</param>
public SummarizationCompactionStrategy(IChatClient chatClient, int maxGroupsBeforeSummary, string? summarizationPrompt = null)
/// <param name="chatClient">The <see cref="IChatClient"/> to use for generating summaries. A smaller, faster model is recommended.</param>
/// <param name="trigger">
/// The <see cref="CompactionTrigger"/> that controls when compaction proceeds.
/// </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"/>,
/// <see cref="DefaultSummarizationPrompt"/> is used.
/// </param>
public SummarizationCompactionStrategy(
IChatClient chatClient,
CompactionTrigger trigger,
int preserveRecentGroups = 4,
string? summarizationPrompt = null)
: base(trigger)
{
this.ChatClient = Throw.IfNull(chatClient);
this.MaxGroupsBeforeSummary = maxGroupsBeforeSummary;
this.PreserveRecentGroups = preserveRecentGroups;
this.SummarizationPrompt = summarizationPrompt ?? DefaultSummarizationPrompt;
}
@@ -48,9 +76,9 @@ public sealed class SummarizationCompactionStrategy : ICompactionStrategy
public IChatClient ChatClient { get; }
/// <summary>
/// Gets the maximum number of included groups allowed before summarization is triggered.
/// Gets the number of most-recent non-system groups to protect from summarization.
/// </summary>
public int MaxGroupsBeforeSummary { get; }
public int PreserveRecentGroups { get; }
/// <summary>
/// Gets the prompt used when requesting summaries from the chat client.
@@ -58,25 +86,35 @@ public sealed class SummarizationCompactionStrategy : ICompactionStrategy
public string SummarizationPrompt { get; }
/// <inheritdoc/>
public async Task<bool> CompactAsync(MessageIndex groups, CancellationToken cancellationToken = default)
protected override async Task<bool> ApplyCompactionAsync(MessageIndex index, CancellationToken cancellationToken)
{
int includedCount = groups.IncludedGroupCount;
if (includedCount <= this.MaxGroupsBeforeSummary)
// Count non-system, non-excluded groups to determine which are protected
int nonSystemIncludedCount = 0;
for (int i = 0; i < index.Groups.Count; i++)
{
MessageGroup group = index.Groups[i];
if (!group.IsExcluded && group.Kind != MessageGroupKind.System)
{
nonSystemIncludedCount++;
}
}
int protectedFromEnd = Math.Min(this.PreserveRecentGroups, nonSystemIncludedCount);
int groupsToSummarize = nonSystemIncludedCount - protectedFromEnd;
if (groupsToSummarize <= 0)
{
return false;
}
// Determine how many groups to summarize (keep the most recent MaxGroupsBeforeSummary groups)
int groupsToSummarize = includedCount - this.MaxGroupsBeforeSummary;
// Collect the oldest non-system included groups for summarization
StringBuilder conversationText = new();
int summarized = 0;
int insertIndex = -1;
for (int i = 0; i < groups.Groups.Count && summarized < groupsToSummarize; i++)
for (int i = 0; i < index.Groups.Count && summarized < groupsToSummarize; i++)
{
MessageGroup group = groups.Groups[i];
MessageGroup group = index.Groups[i];
if (group.IsExcluded || group.Kind == MessageGroupKind.System)
{
continue;
@@ -98,7 +136,7 @@ public sealed class SummarizationCompactionStrategy : ICompactionStrategy
}
group.IsExcluded = true;
group.ExcludeReason = "Summarized by SummarizationCompactionStrategy";
group.ExcludeReason = $"Summarized by {nameof(SummarizationCompactionStrategy)}";
summarized++;
}
@@ -111,23 +149,27 @@ public sealed class SummarizationCompactionStrategy : ICompactionStrategy
ChatResponse response = await this.ChatClient.GetResponseAsync(
[
new ChatMessage(ChatRole.System, this.SummarizationPrompt),
.. index.Groups
.Where(g => !g.IsExcluded && g.Kind == MessageGroupKind.System)
.SelectMany(g => g.Messages),
new ChatMessage(ChatRole.User, conversationText.ToString()),
new ChatMessage(ChatRole.User, "Summarize the conversation above concisely."),
],
cancellationToken: cancellationToken).ConfigureAwait(false);
string summaryText = response.Text ?? string.Empty;
string summaryText = string.IsNullOrWhiteSpace(response.Text) ? "[Summary unavailable]" : response.Text;
// Insert a summary group at the position of the first summarized group
ChatMessage summaryMessage = new(ChatRole.Assistant, $"[Summary of earlier conversation]: {summaryText}");
ChatMessage summaryMessage = new(ChatRole.Assistant, $"[Summary]\n{summaryText}");
(summaryMessage.AdditionalProperties ??= [])[MessageGroup.SummaryPropertyKey] = true;
if (insertIndex >= 0)
{
groups.InsertGroup(insertIndex, MessageGroupKind.Summary, [summaryMessage]);
index.InsertGroup(insertIndex, MessageGroupKind.Summary, [summaryMessage]);
}
else
{
groups.AddGroup(MessageGroupKind.Summary, [summaryMessage]);
index.AddGroup(MessageGroupKind.Summary, [summaryMessage]);
}
return true;
@@ -0,0 +1,117 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Compaction;
/// <summary>
/// A compaction strategy that collapses old 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="MessageGroupKind.ToolCall"/>
/// groups 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 <see cref="CompactionTrigger"/> predicate controls when compaction proceeds.
/// When <see langword="null"/>, a default compound trigger of
/// <see cref="CompactionTriggers.TokensExceed"/> AND <see cref="CompactionTriggers.HasToolCalls"/>
/// is used.
/// </para>
/// </remarks>
public sealed class ToolResultCompactionStrategy : CompactionStrategy
{
/// <summary>
/// The default number of most-recent non-system groups to protect from collapsing.
/// </summary>
public const int DefaultPreserveRecentGroups = 2;
/// <summary>
/// Initializes a new instance of the <see cref="ToolResultCompactionStrategy"/> class.
/// </summary>
/// <param name="trigger">
/// The <see cref="CompactionTrigger"/> that controls when compaction proceeds.
/// </param>
/// <param name="preserveRecentGroups">
/// The number of most-recent non-system message groups to protect from collapsing.
/// Defaults to <see cref="DefaultPreserveRecentGroups"/>, ensuring the current turn's tool interactions remain visible.
/// </param>
public ToolResultCompactionStrategy(CompactionTrigger trigger, int preserveRecentGroups = DefaultPreserveRecentGroups)
: base(trigger)
{
this.PreserveRecentGroups = preserveRecentGroups;
}
/// <summary>
/// Gets the number of most-recent non-system groups to protect from collapsing.
/// </summary>
public int PreserveRecentGroups { get; }
/// <inheritdoc/>
protected override Task<bool> ApplyCompactionAsync(MessageIndex index, CancellationToken cancellationToken)
{
// Identify protected groups: the N most-recent non-system, non-excluded groups
List<int> nonSystemIncludedIndices = [];
for (int i = 0; i < index.Groups.Count; i++)
{
MessageGroup group = index.Groups[i];
if (!group.IsExcluded && group.Kind != MessageGroupKind.System)
{
nonSystemIncludedIndices.Add(i);
}
}
int protectedStart = Math.Max(0, nonSystemIncludedIndices.Count - this.PreserveRecentGroups);
HashSet<int> protectedGroupIndices = [];
for (int i = protectedStart; i < nonSystemIncludedIndices.Count; i++)
{
protectedGroupIndices.Add(nonSystemIncludedIndices[i]);
}
// Process from end to start so insertions don't shift earlier indices
bool compacted = false;
for (int i = index.Groups.Count - 1; i >= 0; i--)
{
MessageGroup group = index.Groups[i];
if (group.IsExcluded || group.Kind != MessageGroupKind.ToolCall || protectedGroupIndices.Contains(i))
{
continue;
}
// Extract tool names from FunctionCallContent
List<string> toolNames = [];
foreach (ChatMessage message in group.Messages)
{
if (message.Contents is not null)
{
foreach (AIContent content in message.Contents)
{
if (content is FunctionCallContent fcc)
{
toolNames.Add(fcc.Name);
}
}
}
}
// Exclude the original group and insert a collapsed replacement
group.IsExcluded = true;
group.ExcludeReason = $"Collapsed by {nameof(ToolResultCompactionStrategy)}";
string summary = $"[Tool calls: {string.Join(", ", toolNames)}]";
index.InsertGroup(i + 1, MessageGroupKind.AssistantText, [new ChatMessage(ChatRole.Assistant, summary)], group.TurnIndex);
compacted = true;
}
return Task.FromResult(compacted);
}
}
@@ -6,72 +6,82 @@ using System.Threading.Tasks;
namespace Microsoft.Agents.AI.Compaction;
/// <summary>
/// A compaction strategy that keeps the most recent message groups up to a specified limit,
/// optionally preserving system message groups.
/// A compaction strategy that removes the oldest non-system message groups,
/// keeping the most recent groups up to <see cref="PreserveRecentGroups"/>.
/// </summary>
/// <remarks>
/// <para>
/// This strategy implements a sliding window approach: it marks older groups as excluded
/// while keeping the most recent groups within the configured <see cref="MaxGroups"/> limit.
/// System message groups can optionally be preserved regardless of their position.
/// 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>
/// This strategy respects atomic group preservation — tool call groups (assistant message + tool results)
/// are always kept or excluded together.
/// The <see cref="CompactionTrigger"/> controls when compaction proceeds.
/// Use <see cref="CompactionTriggers"/> for common trigger conditions such as token or group thresholds.
/// </para>
/// </remarks>
public sealed class TruncationCompactionStrategy : ICompactionStrategy
public sealed class TruncationCompactionStrategy : CompactionStrategy
{
/// <summary>
/// The default number of most-recent non-system groups to protect from collapsing.
/// </summary>
public const int DefaultPreserveRecentGroups = 32;
/// <summary>
/// Initializes a new instance of the <see cref="TruncationCompactionStrategy"/> class.
/// </summary>
/// <param name="maxGroups">The maximum number of message groups to keep. Must be greater than zero.</param>
/// <param name="preserveSystemMessages">Whether to preserve system message groups regardless of position. Defaults to <see langword="true"/>.</param>
public TruncationCompactionStrategy(int maxGroups, bool preserveSystemMessages = true)
/// <param name="trigger">
/// The <see cref="CompactionTrigger"/> that controls when compaction proceeds.
/// </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(CompactionTrigger trigger, int preserveRecentGroups = DefaultPreserveRecentGroups)
: base(trigger)
{
this.MaxGroups = maxGroups;
this.PreserveSystemMessages = preserveSystemMessages;
this.PreserveRecentGroups = preserveRecentGroups;
}
/// <summary>
/// Gets the maximum number of message groups to retain after compaction.
/// Gets the minimum number of most-recent non-system message groups to retain after compaction.
/// </summary>
public int MaxGroups { get; }
/// <summary>
/// Gets a value indicating whether system message groups are preserved regardless of their position in the conversation.
/// </summary>
public bool PreserveSystemMessages { get; }
public int PreserveRecentGroups { get; }
/// <inheritdoc/>
public Task<bool> CompactAsync(MessageIndex groups, CancellationToken cancellationToken = default)
protected override Task<bool> ApplyCompactionAsync(MessageIndex index, CancellationToken cancellationToken)
{
int includedCount = groups.IncludedGroupCount;
if (includedCount <= this.MaxGroups)
// Count removable (non-system, non-excluded) groups
int removableCount = 0;
for (int i = 0; i < index.Groups.Count; i++)
{
MessageGroup group = index.Groups[i];
if (!group.IsExcluded && group.Kind != MessageGroupKind.System)
{
removableCount++;
}
}
int maxRemovable = removableCount - this.PreserveRecentGroups;
if (maxRemovable <= 0)
{
return Task.FromResult(false);
}
int excessCount = includedCount - this.MaxGroups;
bool compacted = false;
// Exclude oldest non-system groups first (iterate from the beginning)
for (int i = 0; i < groups.Groups.Count && excessCount > 0; i++)
bool compacted = false;
int removed = 0;
for (int i = 0; i < index.Groups.Count && removed < maxRemovable; i++)
{
MessageGroup group = groups.Groups[i];
if (group.IsExcluded)
{
continue;
}
if (this.PreserveSystemMessages && group.Kind == MessageGroupKind.System)
MessageGroup group = index.Groups[i];
if (group.IsExcluded || group.Kind == MessageGroupKind.System)
{
continue;
}
group.IsExcluded = true;
group.ExcludeReason = "Truncated by TruncationCompactionStrategy";
excessCount--;
group.ExcludeReason = $"Truncated by {nameof(TruncationCompactionStrategy)}";
removed++;
compacted = true;
}
@@ -18,10 +18,14 @@
<ItemGroup>
<ProjectReference Include="..\Microsoft.Agents.AI.Abstractions\Microsoft.Agents.AI.Abstractions.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.AI" />
<PackageReference Include="Microsoft.Extensions.VectorData.Abstractions" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.ML.Tokenizers" />
<PackageReference Include="System.Diagnostics.DiagnosticSource" />
</ItemGroup>
@@ -36,7 +40,7 @@
<InternalsVisibleTo Include="Microsoft.Agents.AI.UnitTests" />
<InternalsVisibleTo Include="Microsoft.Agents.AI.Declarative.UnitTests" />
<InternalsVisibleTo Include="Microsoft.Agents.AI.Hosting.UnitTests"/>
<InternalsVisibleTo Include="Microsoft.Agents.AI.Hosting.UnitTests" />
</ItemGroup>
</Project>
@@ -1,282 +0,0 @@
// 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;
/// <summary>
/// Contains tests for the <see cref="PipelineCompactionStrategy"/> class.
/// </summary>
public class PipelineCompactionStrategyTests
{
[Fact]
public async Task CompactAsync_ExecutesAllStrategiesInOrder()
{
// Arrange
List<string> executionOrder = [];
Mock<ICompactionStrategy> strategy1 = new();
strategy1.Setup(s => s.CompactAsync(It.IsAny<MessageIndex>(), It.IsAny<CancellationToken>()))
.Callback(() => executionOrder.Add("first"))
.ReturnsAsync(false);
Mock<ICompactionStrategy> strategy2 = new();
strategy2.Setup(s => s.CompactAsync(It.IsAny<MessageIndex>(), It.IsAny<CancellationToken>()))
.Callback(() => executionOrder.Add("second"))
.ReturnsAsync(false);
PipelineCompactionStrategy pipeline = new([strategy1.Object, strategy2.Object]);
MessageIndex groups = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]);
// Act
await pipeline.CompactAsync(groups);
// Assert
Assert.Equal(["first", "second"], executionOrder);
}
[Fact]
public async Task CompactAsync_ReturnsFalse_WhenNoStrategyCompacts()
{
// Arrange
Mock<ICompactionStrategy> strategy1 = new();
strategy1.Setup(s => s.CompactAsync(It.IsAny<MessageIndex>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(false);
PipelineCompactionStrategy pipeline = new([strategy1.Object]);
MessageIndex groups = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]);
// Act
bool result = await pipeline.CompactAsync(groups);
// Assert
Assert.False(result);
}
[Fact]
public async Task CompactAsync_ReturnsTrue_WhenAnyStrategyCompacts()
{
// Arrange
Mock<ICompactionStrategy> strategy1 = new();
strategy1.Setup(s => s.CompactAsync(It.IsAny<MessageIndex>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(false);
Mock<ICompactionStrategy> strategy2 = new();
strategy2.Setup(s => s.CompactAsync(It.IsAny<MessageIndex>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
PipelineCompactionStrategy pipeline = new([strategy1.Object, strategy2.Object]);
MessageIndex groups = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]);
// Act
bool result = await pipeline.CompactAsync(groups);
// Assert
Assert.True(result);
}
[Fact]
public async Task CompactAsync_ContinuesAfterFirstCompaction_WhenEarlyStopDisabled()
{
// Arrange
Mock<ICompactionStrategy> strategy1 = new();
strategy1.Setup(s => s.CompactAsync(It.IsAny<MessageIndex>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
Mock<ICompactionStrategy> strategy2 = new();
strategy2.Setup(s => s.CompactAsync(It.IsAny<MessageIndex>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(false);
PipelineCompactionStrategy pipeline = new([strategy1.Object, strategy2.Object]);
MessageIndex groups = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]);
// Act
await pipeline.CompactAsync(groups);
// Assert — both strategies were called
strategy1.Verify(s => s.CompactAsync(It.IsAny<MessageIndex>(), It.IsAny<CancellationToken>()), Times.Once);
strategy2.Verify(s => s.CompactAsync(It.IsAny<MessageIndex>(), It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task CompactAsync_StopsEarly_WhenTargetReached()
{
// Arrange — first strategy reduces to target
Mock<ICompactionStrategy> strategy1 = new();
strategy1.Setup(s => s.CompactAsync(It.IsAny<MessageIndex>(), It.IsAny<CancellationToken>()))
.Callback<MessageIndex, CancellationToken>((groups, _) =>
{
// Exclude the first group to bring count down
groups.Groups[0].IsExcluded = true;
})
.ReturnsAsync(true);
Mock<ICompactionStrategy> strategy2 = new();
strategy2.Setup(s => s.CompactAsync(It.IsAny<MessageIndex>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(false);
PipelineCompactionStrategy pipeline = new([strategy1.Object, strategy2.Object])
{
EarlyStop = true,
TargetIncludedGroupCount = 2,
};
MessageIndex groups = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "First"),
new ChatMessage(ChatRole.Assistant, "Response"),
new ChatMessage(ChatRole.User, "Second"),
]);
// Act
bool result = await pipeline.CompactAsync(groups);
// Assert — strategy2 should not have been called
Assert.True(result);
strategy1.Verify(s => s.CompactAsync(It.IsAny<MessageIndex>(), It.IsAny<CancellationToken>()), Times.Once);
strategy2.Verify(s => s.CompactAsync(It.IsAny<MessageIndex>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task CompactAsync_DoesNotStopEarly_WhenTargetNotReached()
{
// Arrange — first strategy does NOT bring count to target
Mock<ICompactionStrategy> strategy1 = new();
strategy1.Setup(s => s.CompactAsync(It.IsAny<MessageIndex>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(false);
Mock<ICompactionStrategy> strategy2 = new();
strategy2.Setup(s => s.CompactAsync(It.IsAny<MessageIndex>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(false);
PipelineCompactionStrategy pipeline = new([strategy1.Object, strategy2.Object])
{
EarlyStop = true,
TargetIncludedGroupCount = 1,
};
MessageIndex groups = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "First"),
new ChatMessage(ChatRole.User, "Second"),
new ChatMessage(ChatRole.User, "Third"),
]);
// Act
await pipeline.CompactAsync(groups);
// Assert — both strategies were called since target was never reached
strategy1.Verify(s => s.CompactAsync(It.IsAny<MessageIndex>(), It.IsAny<CancellationToken>()), Times.Once);
strategy2.Verify(s => s.CompactAsync(It.IsAny<MessageIndex>(), It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task CompactAsync_EarlyStopIgnored_WhenNoTargetSet()
{
// Arrange
Mock<ICompactionStrategy> strategy1 = new();
strategy1.Setup(s => s.CompactAsync(It.IsAny<MessageIndex>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
Mock<ICompactionStrategy> strategy2 = new();
strategy2.Setup(s => s.CompactAsync(It.IsAny<MessageIndex>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(false);
PipelineCompactionStrategy pipeline = new([strategy1.Object, strategy2.Object])
{
EarlyStop = true,
// TargetIncludedGroupCount is null
};
MessageIndex groups = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]);
// Act
await pipeline.CompactAsync(groups);
// Assert — both strategies called because no target to check against
strategy1.Verify(s => s.CompactAsync(It.IsAny<MessageIndex>(), It.IsAny<CancellationToken>()), Times.Once);
strategy2.Verify(s => s.CompactAsync(It.IsAny<MessageIndex>(), It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task CompactAsync_ComposesStrategies_EndToEnd()
{
// Arrange — pipeline: first exclude oldest 2 non-system groups, then exclude 2 more
Mock<ICompactionStrategy> phase1 = new();
phase1.Setup(s => s.CompactAsync(It.IsAny<MessageIndex>(), It.IsAny<CancellationToken>()))
.Callback<MessageIndex, CancellationToken>((groups, _) =>
{
int excluded = 0;
foreach (MessageGroup group in groups.Groups)
{
if (!group.IsExcluded && group.Kind != MessageGroupKind.System && excluded < 2)
{
group.IsExcluded = true;
excluded++;
}
}
})
.ReturnsAsync(true);
Mock<ICompactionStrategy> phase2 = new();
phase2.Setup(s => s.CompactAsync(It.IsAny<MessageIndex>(), It.IsAny<CancellationToken>()))
.Callback<MessageIndex, CancellationToken>((groups, _) =>
{
int excluded = 0;
foreach (MessageGroup group in groups.Groups)
{
if (!group.IsExcluded && group.Kind != MessageGroupKind.System && excluded < 2)
{
group.IsExcluded = true;
excluded++;
}
}
})
.ReturnsAsync(true);
PipelineCompactionStrategy pipeline = new([phase1.Object, phase2.Object]);
MessageIndex groups = MessageIndex.Create(
[
new ChatMessage(ChatRole.System, "You are helpful."),
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
new ChatMessage(ChatRole.Assistant, "A2"),
new ChatMessage(ChatRole.User, "Q3"),
]);
// Act
bool result = await pipeline.CompactAsync(groups);
// Assert — system is preserved, phase1 excluded Q1+A1, phase2 excluded Q2+A2 → System + Q3
Assert.True(result);
Assert.Equal(2, groups.IncludedGroupCount);
List<ChatMessage> included = [.. groups.GetIncludedMessages()];
Assert.Equal(2, included.Count);
Assert.Equal("You are helpful.", included[0].Text);
Assert.Equal("Q3", included[1].Text);
phase1.Verify(s => s.CompactAsync(It.IsAny<MessageIndex>(), It.IsAny<CancellationToken>()), Times.Once);
phase2.Verify(s => s.CompactAsync(It.IsAny<MessageIndex>(), It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task CompactAsync_EmptyPipeline_ReturnsFalseAsync()
{
// Arrange
PipelineCompactionStrategy pipeline = new(new List<ICompactionStrategy>());
MessageIndex groups = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]);
// Act
bool result = await pipeline.CompactAsync(groups);
// Assert
Assert.False(result);
}
}
@@ -0,0 +1,155 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.Compaction;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.UnitTests.Compaction;
/// <summary>
/// Contains tests for <see cref="CompactionTrigger"/> and <see cref="CompactionTriggers"/>.
/// </summary>
public class CompactionTriggersTests
{
[Fact]
public void TokensExceed_ReturnsTrueWhenAboveThreshold()
{
// Arrange — use a long message to guarantee tokens > 0
CompactionTrigger trigger = CompactionTriggers.TokensExceed(0);
MessageIndex index = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello world")]);
// Act & Assert
Assert.True(trigger(index));
}
[Fact]
public void TokensExceed_ReturnsFalseWhenBelowThreshold()
{
CompactionTrigger trigger = CompactionTriggers.TokensExceed(999_999);
MessageIndex index = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hi")]);
Assert.False(trigger(index));
}
[Fact]
public void MessagesExceed_ReturnsExpectedResult()
{
CompactionTrigger trigger = CompactionTriggers.MessagesExceed(2);
MessageIndex small = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "A"),
new ChatMessage(ChatRole.User, "B"),
]);
MessageIndex large = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "A"),
new ChatMessage(ChatRole.User, "B"),
new ChatMessage(ChatRole.User, "C"),
]);
Assert.False(trigger(small));
Assert.True(trigger(large));
}
[Fact]
public void TurnsExceed_ReturnsExpectedResult()
{
CompactionTrigger trigger = CompactionTriggers.TurnsExceed(1);
MessageIndex oneTurn = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
]);
MessageIndex twoTurns = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
]);
Assert.False(trigger(oneTurn));
Assert.True(trigger(twoTurns));
}
[Fact]
public void GroupsExceed_ReturnsExpectedResult()
{
CompactionTrigger trigger = CompactionTriggers.GroupsExceed(2);
MessageIndex index = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "A"),
new ChatMessage(ChatRole.Assistant, "B"),
new ChatMessage(ChatRole.User, "C"),
]);
Assert.True(trigger(index));
}
[Fact]
public void HasToolCalls_ReturnsTrueWhenToolCallGroupExists()
{
CompactionTrigger trigger = CompactionTriggers.HasToolCalls();
MessageIndex index = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "fn")]),
new ChatMessage(ChatRole.Tool, "result"),
]);
Assert.True(trigger(index));
}
[Fact]
public void HasToolCalls_ReturnsFalseWhenNoToolCallGroup()
{
CompactionTrigger trigger = CompactionTriggers.HasToolCalls();
MessageIndex index = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Hello"),
new ChatMessage(ChatRole.Assistant, "Hi!"),
]);
Assert.False(trigger(index));
}
[Fact]
public void All_RequiresAllConditions()
{
CompactionTrigger trigger = CompactionTriggers.All(
CompactionTriggers.TokensExceed(0),
CompactionTriggers.MessagesExceed(5));
MessageIndex small = MessageIndex.Create([new ChatMessage(ChatRole.User, "A")]);
// Tokens > 0 is true, but messages > 5 is false
Assert.False(trigger(small));
}
[Fact]
public void Any_RequiresAtLeastOneCondition()
{
CompactionTrigger trigger = CompactionTriggers.Any(
CompactionTriggers.TokensExceed(999_999),
CompactionTriggers.MessagesExceed(0));
MessageIndex index = MessageIndex.Create([new ChatMessage(ChatRole.User, "A")]);
// Tokens not exceeded, but messages > 0 is true
Assert.True(trigger(index));
}
[Fact]
public void All_EmptyTriggers_ReturnsTrue()
{
CompactionTrigger trigger = CompactionTriggers.All();
MessageIndex index = MessageIndex.Create([new ChatMessage(ChatRole.User, "A")]);
Assert.True(trigger(index));
}
[Fact]
public void Any_EmptyTriggers_ReturnsFalse()
{
CompactionTrigger trigger = CompactionTriggers.Any();
MessageIndex index = MessageIndex.Create([new ChatMessage(ChatRole.User, "A")]);
Assert.False(trigger(index));
}
}
@@ -8,7 +8,7 @@
//using Microsoft.Extensions.AI;
//using Moq;
//namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction;
//namespace Microsoft.Agents.AI.UnitTests.Compaction;
///// <summary>
///// Contains tests for the compaction integration with <see cref="InMemoryChatHistoryProvider"/>.
@@ -1,10 +1,10 @@
// Copyright (c) Microsoft. All rights reserved.
// 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;
namespace Microsoft.Agents.AI.UnitTests.Compaction;
/// <summary>
/// Contains tests for the <see cref="MessageIndex"/> class.
@@ -0,0 +1,191 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Compaction;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.UnitTests.Compaction;
/// <summary>
/// Contains tests for the <see cref="PipelineCompactionStrategy"/> class.
/// </summary>
public class PipelineCompactionStrategyTests
{
[Fact]
public async Task CompactAsync_ExecutesAllStrategiesInOrderAsync()
{
// Arrange
List<string> executionOrder = [];
TestCompactionStrategy strategy1 = new(
_ =>
{
executionOrder.Add("first");
return false;
});
TestCompactionStrategy strategy2 = new(
_ =>
{
executionOrder.Add("second");
return false;
});
PipelineCompactionStrategy pipeline = new(strategy1, strategy2);
MessageIndex groups = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]);
// Act
await pipeline.CompactAsync(groups);
// Assert
Assert.Equal(["first", "second"], executionOrder);
}
[Fact]
public async Task CompactAsync_ReturnsFalse_WhenNoStrategyCompactsAsync()
{
// Arrange
TestCompactionStrategy strategy1 = new(_ => false);
PipelineCompactionStrategy pipeline = new(strategy1);
MessageIndex groups = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]);
// Act
bool result = await pipeline.CompactAsync(groups);
// Assert
Assert.False(result);
}
[Fact]
public async Task CompactAsync_ReturnsTrue_WhenAnyStrategyCompactsAsync()
{
// Arrange
TestCompactionStrategy strategy1 = new(_ => false);
TestCompactionStrategy strategy2 = new(_ => true);
PipelineCompactionStrategy pipeline = new(strategy1, strategy2);
MessageIndex groups = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]);
// Act
bool result = await pipeline.CompactAsync(groups);
// Assert
Assert.True(result);
}
[Fact]
public async Task CompactAsync_ContinuesAfterFirstCompaction_WhenEarlyStopDisabledAsync()
{
// Arrange
TestCompactionStrategy strategy1 = new(_ => true);
TestCompactionStrategy strategy2 = new(_ => false);
PipelineCompactionStrategy pipeline = new(strategy1, strategy2);
MessageIndex groups = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]);
// Act
await pipeline.CompactAsync(groups);
// Assert — both strategies were called
Assert.Equal(1, strategy1.ApplyCallCount);
Assert.Equal(1, strategy2.ApplyCallCount);
}
[Fact]
public async Task CompactAsync_ComposesStrategies_EndToEndAsync()
{
// Arrange — pipeline: first exclude oldest 2 non-system groups, then exclude 2 more
static void ExcludeOldest2(MessageIndex index)
{
int excluded = 0;
foreach (MessageGroup group in index.Groups)
{
if (!group.IsExcluded && group.Kind != MessageGroupKind.System && excluded < 2)
{
group.IsExcluded = true;
excluded++;
}
}
}
TestCompactionStrategy phase1 = new(
index =>
{
ExcludeOldest2(index);
return true;
});
TestCompactionStrategy phase2 = new(
index =>
{
ExcludeOldest2(index);
return true;
});
PipelineCompactionStrategy pipeline = new(phase1, phase2);
MessageIndex groups = MessageIndex.Create(
[
new ChatMessage(ChatRole.System, "You are helpful."),
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
new ChatMessage(ChatRole.Assistant, "A2"),
new ChatMessage(ChatRole.User, "Q3"),
]);
// Act
bool result = await pipeline.CompactAsync(groups);
// Assert — system is preserved, phase1 excluded Q1+A1, phase2 excluded Q2+A2 → System + Q3
Assert.True(result);
Assert.Equal(2, groups.IncludedGroupCount);
List<ChatMessage> included = [.. groups.GetIncludedMessages()];
Assert.Equal(2, included.Count);
Assert.Equal("You are helpful.", included[0].Text);
Assert.Equal("Q3", included[1].Text);
Assert.Equal(1, phase1.ApplyCallCount);
Assert.Equal(1, phase2.ApplyCallCount);
}
[Fact]
public async Task CompactAsync_EmptyPipeline_ReturnsFalseAsync()
{
// Arrange
PipelineCompactionStrategy pipeline = new(new List<CompactionStrategy>());
MessageIndex groups = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]);
// Act
bool result = await pipeline.CompactAsync(groups);
// Assert
Assert.False(result);
}
/// <summary>
/// A simple test implementation of <see cref="CompactionStrategy"/> that delegates to a synchronous callback.
/// </summary>
private sealed class TestCompactionStrategy : CompactionStrategy
{
private readonly Func<MessageIndex, bool> _applyFunc;
public TestCompactionStrategy(Func<MessageIndex, bool> applyFunc)
: base(CompactionTriggers.Always)
{
this._applyFunc = applyFunc;
}
public int ApplyCallCount { get; private set; }
protected override Task<bool> ApplyCompactionAsync(MessageIndex index, CancellationToken cancellationToken)
{
this.ApplyCallCount++;
return Task.FromResult(this._applyFunc(index));
}
}
}
@@ -0,0 +1,160 @@
// 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.UnitTests.Compaction;
/// <summary>
/// Contains tests for the <see cref="SlidingWindowCompactionStrategy"/> class.
/// </summary>
public class SlidingWindowCompactionStrategyTests
{
[Fact]
public async Task CompactAsync_BelowMaxTurns_ReturnsFalseAsync()
{
// Arrange
SlidingWindowCompactionStrategy strategy = new(maximumTurns: 3);
MessageIndex groups = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
new ChatMessage(ChatRole.Assistant, "A2"),
]);
// Act
bool result = await strategy.CompactAsync(groups);
// Assert
Assert.False(result);
}
[Fact]
public async Task CompactAsync_ExceedsMaxTurns_ExcludesOldestTurnsAsync()
{
// Arrange — keep 2 turns, conversation has 3
SlidingWindowCompactionStrategy strategy = new(maximumTurns: 2);
MessageIndex groups = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
new ChatMessage(ChatRole.Assistant, "A2"),
new ChatMessage(ChatRole.User, "Q3"),
new ChatMessage(ChatRole.Assistant, "A3"),
]);
// Act
bool result = await strategy.CompactAsync(groups);
// Assert
Assert.True(result);
// Turn 1 (Q1 + A1) should be excluded
Assert.True(groups.Groups[0].IsExcluded);
Assert.True(groups.Groups[1].IsExcluded);
// Turn 2 and 3 should remain
Assert.False(groups.Groups[2].IsExcluded);
Assert.False(groups.Groups[3].IsExcluded);
Assert.False(groups.Groups[4].IsExcluded);
Assert.False(groups.Groups[5].IsExcluded);
}
[Fact]
public async Task CompactAsync_PreservesSystemMessagesAsync()
{
// Arrange
SlidingWindowCompactionStrategy strategy = new(maximumTurns: 1);
MessageIndex groups = MessageIndex.Create(
[
new ChatMessage(ChatRole.System, "You are helpful."),
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
]);
// Act
bool result = await strategy.CompactAsync(groups);
// Assert
Assert.True(result);
Assert.False(groups.Groups[0].IsExcluded); // System preserved
Assert.True(groups.Groups[1].IsExcluded); // Turn 1 excluded
Assert.True(groups.Groups[2].IsExcluded); // Turn 1 response excluded
Assert.False(groups.Groups[3].IsExcluded); // Turn 2 kept
}
[Fact]
public async Task CompactAsync_PreservesToolCallGroupsInKeptTurnsAsync()
{
// Arrange
SlidingWindowCompactionStrategy strategy = new(maximumTurns: 1);
MessageIndex groups = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", "search")]),
new ChatMessage(ChatRole.Tool, "Results"),
]);
// Act
bool result = await strategy.CompactAsync(groups);
// Assert
Assert.True(result);
// Turn 1 excluded
Assert.True(groups.Groups[0].IsExcluded);
Assert.True(groups.Groups[1].IsExcluded);
// Turn 2 kept (user + tool call group)
Assert.False(groups.Groups[2].IsExcluded);
Assert.False(groups.Groups[3].IsExcluded);
}
[Fact]
public async Task CompactAsync_CustomTrigger_OverridesDefaultAsync()
{
// Arrange — custom trigger: only compact when tokens exceed threshold
SlidingWindowCompactionStrategy strategy = new(maximumTurns: 99);
MessageIndex groups = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.User, "Q2"),
new ChatMessage(ChatRole.User, "Q3"),
]);
// Act — tokens are tiny, trigger not met
bool result = await strategy.CompactAsync(groups);
// Assert
Assert.False(result);
}
[Fact]
public async Task CompactAsync_IncludedMessages_ContainOnlyKeptTurnsAsync()
{
// Arrange
SlidingWindowCompactionStrategy strategy = new(maximumTurns: 1);
MessageIndex groups = MessageIndex.Create(
[
new ChatMessage(ChatRole.System, "System"),
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
new ChatMessage(ChatRole.Assistant, "A2"),
]);
// Act
await strategy.CompactAsync(groups);
// Assert
List<ChatMessage> included = [.. groups.GetIncludedMessages()];
Assert.Equal(3, included.Count);
Assert.Equal("System", included[0].Text);
Assert.Equal("Q2", included[1].Text);
Assert.Equal("A2", included[2].Text);
}
}
@@ -0,0 +1,195 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Compaction;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.UnitTests.Compaction;
/// <summary>
/// Contains tests for the <see cref="ToolResultCompactionStrategy"/> class.
/// </summary>
public class ToolResultCompactionStrategyTests
{
[Fact]
public async Task CompactAsync_TriggerNotMet_ReturnsFalseAsync()
{
// Arrange — trigger requires > 1000 tokens
ToolResultCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(1000));
ChatMessage toolCall = new(ChatRole.Assistant, [new FunctionCallContent("call1", "get_weather")]);
ChatMessage toolResult = new(ChatRole.Tool, "Sunny");
MessageIndex groups = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "What's the weather?"),
toolCall,
toolResult,
]);
// Act
bool result = await strategy.CompactAsync(groups);
// Assert
Assert.False(result);
}
[Fact]
public async Task CompactAsync_CollapsesOldToolGroupsAsync()
{
// Arrange — always trigger
ToolResultCompactionStrategy strategy = new(
trigger: _ => true,
preserveRecentGroups: 1);
MessageIndex groups = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", "get_weather")]),
new ChatMessage(ChatRole.Tool, "Sunny and 72°F"),
new ChatMessage(ChatRole.User, "Q2"),
]);
// Act
bool result = await strategy.CompactAsync(groups);
// Assert
Assert.True(result);
List<ChatMessage> included = [.. groups.GetIncludedMessages()];
// Q1 + collapsed tool summary + Q2
Assert.Equal(3, included.Count);
Assert.Equal("Q1", included[0].Text);
Assert.Contains("[Tool calls: get_weather]", included[1].Text);
Assert.Equal("Q2", included[2].Text);
}
[Fact]
public async Task CompactAsync_PreservesRecentToolGroupsAsync()
{
// Arrange — protect 2 recent non-system groups (the tool group + Q2)
ToolResultCompactionStrategy strategy = new(
trigger: _ => true,
preserveRecentGroups: 3);
MessageIndex groups = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", "search")]),
new ChatMessage(ChatRole.Tool, "Results"),
new ChatMessage(ChatRole.User, "Q2"),
]);
// Act
bool result = await strategy.CompactAsync(groups);
// Assert — all groups are in the protected window, nothing to collapse
Assert.False(result);
}
[Fact]
public async Task CompactAsync_PreservesSystemMessagesAsync()
{
// Arrange
ToolResultCompactionStrategy strategy = new(
trigger: _ => true,
preserveRecentGroups: 1);
MessageIndex groups = MessageIndex.Create(
[
new ChatMessage(ChatRole.System, "You are helpful."),
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", "fn")]),
new ChatMessage(ChatRole.Tool, "result"),
new ChatMessage(ChatRole.User, "Q2"),
]);
// Act
await strategy.CompactAsync(groups);
// Assert
List<ChatMessage> included = [.. groups.GetIncludedMessages()];
Assert.Equal("You are helpful.", included[0].Text);
}
[Fact]
public async Task CompactAsync_ExtractsMultipleToolNamesAsync()
{
// Arrange — assistant calls two tools
ToolResultCompactionStrategy strategy = new(
trigger: _ => true,
preserveRecentGroups: 1);
ChatMessage multiToolCall = new(ChatRole.Assistant,
[
new FunctionCallContent("c1", "get_weather"),
new FunctionCallContent("c2", "search_docs"),
]);
MessageIndex groups = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
multiToolCall,
new ChatMessage(ChatRole.Tool, "Sunny"),
new ChatMessage(ChatRole.Tool, "Found 3 docs"),
new ChatMessage(ChatRole.User, "Q2"),
]);
// Act
await strategy.CompactAsync(groups);
// Assert
List<ChatMessage> included = [.. groups.GetIncludedMessages()];
string collapsed = included[1].Text!;
Assert.Contains("get_weather", collapsed);
Assert.Contains("search_docs", collapsed);
}
[Fact]
public async Task CompactAsync_NoToolGroups_ReturnsFalseAsync()
{
// Arrange — trigger fires but no tool groups to collapse
ToolResultCompactionStrategy strategy = new(
trigger: _ => true,
preserveRecentGroups: 0);
MessageIndex groups = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Hello"),
new ChatMessage(ChatRole.Assistant, "Hi!"),
]);
// Act
bool result = await strategy.CompactAsync(groups);
// Assert
Assert.False(result);
}
[Fact]
public async Task CompactAsync_CompoundTrigger_RequiresTokensAndToolCallsAsync()
{
// Arrange — compound: tokens > 0 AND has tool calls
ToolResultCompactionStrategy strategy = new(
CompactionTriggers.All(
CompactionTriggers.TokensExceed(0),
CompactionTriggers.HasToolCalls()),
preserveRecentGroups: 1);
MessageIndex groups = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "fn")]),
new ChatMessage(ChatRole.Tool, "result"),
new ChatMessage(ChatRole.User, "Q2"),
]);
// Act
bool result = await strategy.CompactAsync(groups);
// Assert
Assert.True(result);
}
}
@@ -1,5 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Compaction;
using Microsoft.Extensions.AI;
@@ -11,78 +12,91 @@ namespace Microsoft.Agents.AI.UnitTests.Compaction;
/// </summary>
public class TruncationCompactionStrategyTests
{
private static readonly CompactionTrigger s_alwaysTrigger = _ => true;
[Fact]
public async Task CompactAsync_BelowLimit_ReturnsFalseAsync()
public async Task CompactAsync_AlwaysTrigger_CompactsToPreserveRecentAsync()
{
// Arrange
TruncationCompactionStrategy strategy = new(maxGroups: 5);
// Arrange — always-trigger means always compact
TruncationCompactionStrategy strategy = new(s_alwaysTrigger, preserveRecentGroups: 1);
MessageIndex groups = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Hello"),
new ChatMessage(ChatRole.Assistant, "Hi!"),
new ChatMessage(ChatRole.User, "First"),
new ChatMessage(ChatRole.Assistant, "Response 1"),
new ChatMessage(ChatRole.User, "Second"),
]);
// Act
bool result = await strategy.CompactAsync(groups);
// Assert
Assert.False(result);
Assert.Equal(2, groups.IncludedGroupCount);
}
[Fact]
public async Task CompactAsync_AtLimit_ReturnsFalseAsync()
{
// Arrange
TruncationCompactionStrategy strategy = new(maxGroups: 2);
MessageIndex groups = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Hello"),
new ChatMessage(ChatRole.Assistant, "Hi!"),
]);
// Act
bool result = await strategy.CompactAsync(groups);
// Assert
Assert.False(result);
}
[Fact]
public async Task CompactAsync_ExceedsLimit_ExcludesOldestGroupsAsync()
{
// Arrange
TruncationCompactionStrategy strategy = new(maxGroups: 2);
ChatMessage msg1 = new(ChatRole.User, "First");
ChatMessage msg2 = new(ChatRole.Assistant, "Response 1");
ChatMessage msg3 = new(ChatRole.User, "Second");
ChatMessage msg4 = new(ChatRole.Assistant, "Response 2");
MessageIndex groups = MessageIndex.Create([msg1, msg2, msg3, msg4]);
// Act
bool result = await strategy.CompactAsync(groups);
// Assert
Assert.True(result);
Assert.Equal(1, groups.Groups.Count(g => !g.IsExcluded));
}
[Fact]
public async Task CompactAsync_TriggerNotMet_ReturnsFalseAsync()
{
// Arrange — trigger requires > 1000 tokens, conversation is tiny
TruncationCompactionStrategy strategy = new(
preserveRecentGroups: 1,
trigger: CompactionTriggers.TokensExceed(1000));
MessageIndex groups = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Hello"),
new ChatMessage(ChatRole.Assistant, "Hi!"),
]);
// Act
bool result = await strategy.CompactAsync(groups);
// Assert
Assert.False(result);
Assert.Equal(2, groups.IncludedGroupCount);
}
[Fact]
public async Task CompactAsync_TriggerMet_ExcludesOldestGroupsAsync()
{
// Arrange — trigger on groups > 2
TruncationCompactionStrategy strategy = new(
preserveRecentGroups: 1,
trigger: CompactionTriggers.GroupsExceed(2));
MessageIndex groups = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "First"),
new ChatMessage(ChatRole.Assistant, "Response 1"),
new ChatMessage(ChatRole.User, "Second"),
new ChatMessage(ChatRole.Assistant, "Response 2"),
]);
// Act
bool result = await strategy.CompactAsync(groups);
// Assert
Assert.True(result);
Assert.Equal(1, groups.IncludedGroupCount);
// Oldest 3 excluded, newest 1 kept
Assert.True(groups.Groups[0].IsExcluded);
Assert.True(groups.Groups[1].IsExcluded);
Assert.False(groups.Groups[2].IsExcluded);
Assert.True(groups.Groups[2].IsExcluded);
Assert.False(groups.Groups[3].IsExcluded);
}
[Fact]
public async Task CompactAsync_PreservesSystemMessages_WhenEnabledAsync()
public async Task CompactAsync_PreservesSystemMessagesAsync()
{
// Arrange
TruncationCompactionStrategy strategy = new(maxGroups: 2, preserveSystemMessages: true);
ChatMessage systemMsg = new(ChatRole.System, "You are helpful.");
ChatMessage msg1 = new(ChatRole.User, "First");
ChatMessage msg2 = new(ChatRole.Assistant, "Response 1");
ChatMessage msg3 = new(ChatRole.User, "Second");
MessageIndex groups = MessageIndex.Create([systemMsg, msg1, msg2, msg3]);
TruncationCompactionStrategy strategy = new(s_alwaysTrigger, preserveRecentGroups: 1);
MessageIndex groups = MessageIndex.Create(
[
new ChatMessage(ChatRole.System, "You are helpful."),
new ChatMessage(ChatRole.User, "First"),
new ChatMessage(ChatRole.Assistant, "Response 1"),
new ChatMessage(ChatRole.User, "Second"),
]);
// Act
bool result = await strategy.CompactAsync(groups);
@@ -92,34 +106,10 @@ public class TruncationCompactionStrategyTests
// System message should be preserved
Assert.False(groups.Groups[0].IsExcluded);
Assert.Equal(MessageGroupKind.System, groups.Groups[0].Kind);
// Oldest non-system groups should be excluded
// Oldest non-system groups excluded
Assert.True(groups.Groups[1].IsExcluded);
Assert.True(groups.Groups[2].IsExcluded);
// Most recent should remain
Assert.False(groups.Groups[3].IsExcluded);
}
[Fact]
public async Task CompactAsync_DoesNotPreserveSystemMessages_WhenDisabledAsync()
{
// Arrange
TruncationCompactionStrategy strategy = new(maxGroups: 2, preserveSystemMessages: false);
ChatMessage systemMsg = new(ChatRole.System, "You are helpful.");
ChatMessage msg1 = new(ChatRole.User, "First");
ChatMessage msg2 = new(ChatRole.Assistant, "Response");
ChatMessage msg3 = new(ChatRole.User, "Second");
MessageIndex groups = MessageIndex.Create([systemMsg, msg1, msg2, msg3]);
// Act
bool result = await strategy.CompactAsync(groups);
// Assert
Assert.True(result);
// System message should be excluded (oldest)
Assert.True(groups.Groups[0].IsExcluded);
Assert.True(groups.Groups[1].IsExcluded);
Assert.False(groups.Groups[2].IsExcluded);
// Most recent kept
Assert.False(groups.Groups[3].IsExcluded);
}
@@ -127,9 +117,9 @@ public class TruncationCompactionStrategyTests
public async Task CompactAsync_PreservesToolCallGroupAtomicityAsync()
{
// Arrange
TruncationCompactionStrategy strategy = new(maxGroups: 1);
TruncationCompactionStrategy strategy = new(s_alwaysTrigger, preserveRecentGroups: 1);
ChatMessage assistantToolCall = new(ChatRole.Assistant, [new FunctionCallContent("call1", "get_weather")]);
ChatMessage assistantToolCall= new(ChatRole.Assistant, [new FunctionCallContent("call1", "get_weather")]);
ChatMessage toolResult = new(ChatRole.Tool, "Sunny");
ChatMessage finalResponse = new(ChatRole.User, "Thanks!");
@@ -151,7 +141,7 @@ public class TruncationCompactionStrategyTests
public async Task CompactAsync_SetsExcludeReasonAsync()
{
// Arrange
TruncationCompactionStrategy strategy = new(maxGroups: 1);
TruncationCompactionStrategy strategy = new(s_alwaysTrigger, preserveRecentGroups: 1);
MessageIndex groups = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Old"),
@@ -170,7 +160,7 @@ public class TruncationCompactionStrategyTests
public async Task CompactAsync_SkipsAlreadyExcludedGroupsAsync()
{
// Arrange
TruncationCompactionStrategy strategy = new(maxGroups: 1);
TruncationCompactionStrategy strategy = new(s_alwaysTrigger, preserveRecentGroups: 1);
MessageIndex groups = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Already excluded"),
@@ -188,4 +178,46 @@ public class TruncationCompactionStrategyTests
Assert.True(groups.Groups[1].IsExcluded); // newly excluded
Assert.False(groups.Groups[2].IsExcluded); // kept
}
[Fact]
public async Task CompactAsync_PreserveRecentGroups_KeepsMultipleAsync()
{
// Arrange — keep 2 most recent
TruncationCompactionStrategy strategy = new(s_alwaysTrigger, preserveRecentGroups: 2);
MessageIndex groups = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
new ChatMessage(ChatRole.Assistant, "A2"),
]);
// Act
bool result = await strategy.CompactAsync(groups);
// Assert
Assert.True(result);
Assert.True(groups.Groups[0].IsExcluded);
Assert.True(groups.Groups[1].IsExcluded);
Assert.False(groups.Groups[2].IsExcluded);
Assert.False(groups.Groups[3].IsExcluded);
}
[Fact]
public async Task CompactAsync_NothingToRemove_ReturnsFalseAsync()
{
// Arrange — preserve 5 but only 2 groups
TruncationCompactionStrategy strategy = new(s_alwaysTrigger, preserveRecentGroups: 5);
MessageIndex groups = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Hello"),
new ChatMessage(ChatRole.Assistant, "Hi!"),
]);
// Act
bool result = await strategy.CompactAsync(groups);
// Assert
Assert.False(result);
}
}