diff --git a/dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/Program.cs b/dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/Program.cs
index 82a38c538c..0a57de892e 100644
--- a/dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/Program.cs
+++ b/dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/Program.cs
@@ -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 =
diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ICompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ICompactionStrategy.cs
deleted file mode 100644
index c894dd0d19..0000000000
--- a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ICompactionStrategy.cs
+++ /dev/null
@@ -1,38 +0,0 @@
-// Copyright (c) Microsoft. All rights reserved.
-
-using System.Threading;
-using System.Threading.Tasks;
-
-namespace Microsoft.Agents.AI.Compaction;
-
-///
-/// Defines a strategy for compacting a to reduce context size.
-///
-///
-///
-/// Compaction strategies operate on 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).
-///
-///
-/// Strategies can be applied at three lifecycle points:
-///
-/// - In-run: During the tool loop, before each LLM call, to keep context within token limits.
-/// - Pre-write: Before persisting messages to storage via .
-/// - On existing storage: As a maintenance operation to compact stored history.
-///
-///
-///
-/// Multiple strategies can be composed by applying them sequentially to the same .
-///
-///
-public interface ICompactionStrategy
-{
- ///
- /// Compacts the specified message groups in place.
- ///
- /// The message group collection to compact. The strategy mutates this collection in place.
- /// The to monitor for cancellation requests.
- /// A task representing the asynchronous operation. The task result is if compaction occurred, otherwise.
- Task CompactAsync(MessageIndex groups, CancellationToken cancellationToken = default);
-}
diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/PipelineCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/PipelineCompactionStrategy.cs
deleted file mode 100644
index 2346d03c32..0000000000
--- a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/PipelineCompactionStrategy.cs
+++ /dev/null
@@ -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;
-
-///
-/// A compaction strategy that executes a sequential pipeline of instances
-/// against the same .
-///
-///
-///
-/// 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.
-///
-///
-/// When is and a 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.
-///
-///
-public sealed class PipelineCompactionStrategy : ICompactionStrategy
-{
- ///
- /// Initializes a new instance of the class.
- ///
- /// The ordered sequence of strategies to execute. Must not be empty.
- ///// An optional cache for instances. When , a default is created.
- public PipelineCompactionStrategy(params IEnumerable strategies/*, IMessageIndexCache? cache = null*/)
- {
- this.Strategies = [.. Throw.IfNull(strategies)];
- }
-
- ///
- /// Gets the ordered list of strategies in this pipeline.
- ///
- public IReadOnlyList Strategies { get; }
-
- ///
- /// Gets or sets a value indicating whether the pipeline should stop executing after a strategy
- /// brings the included group count to or below .
- ///
- ///
- /// Defaults to , meaning all strategies are always executed.
- ///
- public bool EarlyStop { get; set; }
-
- ///
- /// Gets or sets the target number of included groups at which the pipeline stops
- /// when is .
- ///
- ///
- /// Defaults to , meaning early stop checks are not performed
- /// even when is .
- ///
- public int? TargetIncludedGroupCount { get; set; }
-
- ///
- public async Task 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;
- }
-}
diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/InMemoryChatHistoryProviderOptions.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/InMemoryChatHistoryProviderOptions.cs
index 5d15bb416b..ba24f55ded 100644
--- a/dotnet/src/Microsoft.Agents.AI.Abstractions/InMemoryChatHistoryProviderOptions.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/InMemoryChatHistoryProviderOptions.cs
@@ -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;
diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Microsoft.Agents.AI.Abstractions.csproj b/dotnet/src/Microsoft.Agents.AI.Abstractions/Microsoft.Agents.AI.Abstractions.csproj
index b097386b14..e31093e174 100644
--- a/dotnet/src/Microsoft.Agents.AI.Abstractions/Microsoft.Agents.AI.Abstractions.csproj
+++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Microsoft.Agents.AI.Abstractions.csproj
@@ -29,7 +29,6 @@
-
diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentOptions.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentOptions.cs
index 7deff4056c..28b5643a30 100644
--- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentOptions.cs
+++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentOptions.cs
@@ -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? AIContextProviders { get; set; }
///
- /// Gets or sets the to use for in-run context compaction.
+ /// Gets or sets the to use for in-run context compaction.
///
///
///
@@ -57,10 +57,10 @@ public sealed class ChatClientAgentOptions
///
///
/// The strategy organizes messages into atomic groups (preserving tool-call/result pairings)
- /// before applying compaction logic. See for details.
+ /// before applying compaction logic. See for details.
///
///
- public ICompactionStrategy? CompactionStrategy { get; set; }
+ public CompactionStrategy? CompactionStrategy { get; set; }
///
/// Gets or sets a value indicating whether to use the provided instance as is,
diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactingChatClient.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactingChatClient.cs
index c7fbf66115..90e97bc611 100644
--- a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactingChatClient.cs
+++ b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactingChatClient.cs
@@ -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;
///
-/// A delegating that applies an to the message list
+/// A delegating that applies an to the message list
/// before each call to the inner chat client.
///
///
@@ -28,7 +29,7 @@ namespace Microsoft.Agents.AI.Compaction;
///
internal sealed class CompactingChatClient : DelegatingChatClient
{
- private readonly ICompactionStrategy _compactionStrategy;
+ private readonly CompactionStrategy _compactionStrategy;
private readonly ProviderSessionState _sessionState;
///
@@ -36,7 +37,7 @@ internal sealed class CompactingChatClient : DelegatingChatClient
///
/// The inner chat client to delegate to.
/// The compaction strategy to apply before each call.
- 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)
{
diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionStrategy.cs
new file mode 100644
index 0000000000..2bc1e7abd8
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionStrategy.cs
@@ -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;
+
+///
+/// Base class for strategies that compact a to reduce context size.
+///
+///
+///
+/// Compaction strategies operate on 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).
+///
+///
+/// Every strategy requires a that determines whether compaction should
+/// proceed based on current metrics (token count, message count, turn count, etc.).
+/// The base class evaluates this trigger at the start of and skips compaction when
+/// the trigger returns .
+///
+///
+/// Strategies can be applied at three lifecycle points:
+///
+/// - In-run: During the tool loop, before each LLM call, to keep context within token limits.
+/// - Pre-write: Before persisting messages to storage via .
+/// - On existing storage: As a maintenance operation to compact stored history.
+///
+///
+///
+/// Multiple strategies can be composed by applying them sequentially to the same
+/// via .
+///
+///
+public abstract class CompactionStrategy
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ ///
+ /// The that determines whether compaction should proceed.
+ ///
+ protected CompactionStrategy(CompactionTrigger trigger)
+ {
+ this.Trigger = Throw.IfNull(trigger);
+ }
+
+ ///
+ /// Gets the trigger predicate that controls when compaction proceeds.
+ ///
+ protected CompactionTrigger Trigger { get; }
+
+ ///
+ /// Evaluates the and, when it fires, delegates to
+ /// and reports compaction metrics.
+ ///
+ /// The message index to compact. The strategy mutates this collection in place.
+ /// The to monitor for cancellation requests.
+ /// A task representing the asynchronous operation. The task result is if compaction occurred, otherwise.
+ public async Task 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;
+ }
+
+ ///
+ /// Applies the strategy-specific compaction logic to the specified message index.
+ ///
+ ///
+ /// This method is called by only when the
+ /// returns . Implementations do not need to evaluate the trigger or
+ /// report metrics — the base class handles both.
+ ///
+ /// The message index to compact. The strategy mutates this collection in place.
+ /// The to monitor for cancellation requests.
+ /// A task whose result is if any compaction was performed, otherwise.
+ protected abstract Task ApplyCompactionAsync(MessageIndex index, CancellationToken cancellationToken);
+}
diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionTrigger.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionTrigger.cs
new file mode 100644
index 0000000000..2ca28e2005
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionTrigger.cs
@@ -0,0 +1,10 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+namespace Microsoft.Agents.AI.Compaction;
+
+///
+/// A predicate that evaluates whether compaction should proceed based on current metrics.
+///
+/// The current message index with group, token, message, and turn metrics.
+/// if compaction should proceed; to skip.
+public delegate bool CompactionTrigger(MessageIndex index);
diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionTriggers.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionTriggers.cs
new file mode 100644
index 0000000000..d3eb350ca6
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionTriggers.cs
@@ -0,0 +1,100 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Linq;
+
+namespace Microsoft.Agents.AI.Compaction;
+
+///
+/// Provides factory methods for common predicates.
+///
+///
+/// These triggers evaluate included (non-excluded) metrics from the .
+/// Combine triggers with or for compound conditions,
+/// or write a custom lambda for full flexibility.
+///
+public static class CompactionTriggers
+{
+ ///
+ /// Always triger compaction, regardless of the message index state.
+ ///
+ public static readonly CompactionTrigger Always =
+ _ => true;
+
+ ///
+ /// Creates a trigger that fires when the included token count exceeds the specified maximum.
+ ///
+ /// The token threshold. Compaction proceeds when included tokens exceed this value.
+ /// A that evaluates included token count.
+ public static CompactionTrigger TokensExceed(int maxTokens) =>
+ index => index.IncludedTokenCount > maxTokens;
+
+ ///
+ /// Creates a trigger that fires when the included message count exceeds the specified maximum.
+ ///
+ /// The message threshold. Compaction proceeds when included messages exceed this value.
+ /// A that evaluates included message count.
+ public static CompactionTrigger MessagesExceed(int maxMessages) =>
+ index => index.IncludedMessageCount > maxMessages;
+
+ ///
+ /// Creates a trigger that fires when the included user turn count exceeds the specified maximum.
+ ///
+ /// The turn threshold. Compaction proceeds when included turns exceed this value.
+ /// A that evaluates included turn count.
+ public static CompactionTrigger TurnsExceed(int maxTurns) =>
+ index => index.IncludedTurnCount > maxTurns;
+
+ ///
+ /// Creates a trigger that fires when the included group count exceeds the specified maximum.
+ ///
+ /// The group threshold. Compaction proceeds when included groups exceed this value.
+ /// A that evaluates included group count.
+ public static CompactionTrigger GroupsExceed(int maxGroups) =>
+ index => index.IncludedGroupCount > maxGroups;
+
+ ///
+ /// Creates a trigger that fires when the included message index contains at least one
+ /// non-excluded group.
+ ///
+ /// A that evaluates included tool call presence.
+ public static CompactionTrigger HasToolCalls() =>
+ index => index.Groups.Any(g => !g.IsExcluded && g.Kind == MessageGroupKind.ToolCall);
+
+ ///
+ /// Creates a compound trigger that fires only when all of the specified triggers fire.
+ ///
+ /// The triggers to combine with logical AND.
+ /// A that requires all conditions to be met.
+ public static CompactionTrigger All(params CompactionTrigger[] triggers) =>
+ index =>
+ {
+ for (int i = 0; i < triggers.Length; i++)
+ {
+ if (!triggers[i](index))
+ {
+ return false;
+ }
+ }
+
+ return true;
+ };
+
+ ///
+ /// Creates a compound trigger that fires when any of the specified triggers fire.
+ ///
+ /// The triggers to combine with logical OR.
+ /// A that requires at least one condition to be met.
+ public static CompactionTrigger Any(params CompactionTrigger[] triggers) =>
+ index =>
+ {
+ for (int i = 0; i < triggers.Length; i++)
+ {
+ if (triggers[i](index))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ };
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/MessageGroup.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/MessageGroup.cs
similarity index 100%
rename from dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/MessageGroup.cs
rename to dotnet/src/Microsoft.Agents.AI/Compaction/MessageGroup.cs
diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/MessageGroupKind.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/MessageGroupKind.cs
similarity index 100%
rename from dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/MessageGroupKind.cs
rename to dotnet/src/Microsoft.Agents.AI/Compaction/MessageGroupKind.cs
diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/MessageIndex.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/MessageIndex.cs
similarity index 100%
rename from dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/MessageIndex.cs
rename to dotnet/src/Microsoft.Agents.AI/Compaction/MessageIndex.cs
diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/PipelineCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/PipelineCompactionStrategy.cs
new file mode 100644
index 0000000000..d48b3e7bad
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI/Compaction/PipelineCompactionStrategy.cs
@@ -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;
+
+///
+/// A compaction strategy that executes a sequential pipeline of instances
+/// against the same .
+///
+///
+///
+/// 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.
+///
+///
+/// The pipeline's own is evaluated first. If it returns
+/// , none of the child strategies are executed. Each child strategy also
+/// evaluates its own trigger independently.
+///
+///
+public sealed class PipelineCompactionStrategy : CompactionStrategy
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The ordered sequence of strategies to execute.
+ public PipelineCompactionStrategy(params IEnumerable strategies)
+ : base(CompactionTriggers.Always)
+ {
+ this.Strategies = [.. Throw.IfNull(strategies)];
+ }
+
+ ///
+ /// Gets the ordered list of strategies in this pipeline.
+ ///
+ public IReadOnlyList Strategies { get; }
+
+ ///
+ protected override async Task 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;
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/SlidingWindowCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/SlidingWindowCompactionStrategy.cs
new file mode 100644
index 0000000000..accf974963
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI/Compaction/SlidingWindowCompactionStrategy.cs
@@ -0,0 +1,97 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Microsoft.Agents.AI.Compaction;
+
+///
+/// A compaction strategy that keeps only the most recent user turns and their
+/// associated response groups, removing older turns to bound conversation length.
+///
+///
+///
+/// This strategy always preserves system messages. It identifies user turns in the
+/// conversation (via ) and keeps the last
+/// turns along with all response groups (assistant replies,
+/// tool call groups) that belong to each kept turn.
+///
+///
+/// The predicate controls when compaction proceeds.
+/// When , a default trigger of
+/// with is used.
+///
+///
+/// This strategy is more predictable than token-based truncation for bounding conversation
+/// length, since it operates on logical turn boundaries rather than estimated token counts.
+///
+///
+public sealed class SlidingWindowCompactionStrategy : CompactionStrategy
+{
+ ///
+ /// 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.
+ ///
+ public const int DefaultMaximumTurns = 32;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ ///
+ /// The maximum number of user turns to keep. Older turns and their associated responses are removed.
+ ///
+ public SlidingWindowCompactionStrategy(int maximumTurns = DefaultMaximumTurns)
+ : base(CompactionTriggers.TurnsExceed(maximumTurns))
+ {
+ this.MaxTurns = maximumTurns;
+ }
+
+ ///
+ /// Gets the maximum number of user turns to retain after compaction.
+ ///
+ public int MaxTurns { get; }
+
+ ///
+ protected override Task ApplyCompactionAsync(MessageIndex index, CancellationToken cancellationToken)
+ {
+ // Collect distinct included turn indices in order
+ List 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 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);
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/SummarizationCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/SummarizationCompactionStrategy.cs
index 51c5b4e224..c995783d23 100644
--- a/dotnet/src/Microsoft.Agents.AI/Compaction/SummarizationCompactionStrategy.cs
+++ b/dotnet/src/Microsoft.Agents.AI/Compaction/SummarizationCompactionStrategy.cs
@@ -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;
///
-/// A compaction strategy that summarizes older message groups using an ,
-/// 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.
///
///
///
-/// When the number of included message groups exceeds ,
-/// this strategy extracts the oldest non-system groups (up to the threshold), sends them
-/// to an for summarization, and replaces those groups with a single
-/// assistant message containing the summary.
+/// This strategy protects system messages and the most recent
+/// non-system groups. All older groups are collected and sent to the
+/// for summarization. The resulting summary replaces those messages as a single assistant message
+/// with .
///
///
-/// System message groups are always preserved and never included in summarization.
+/// The predicate controls when compaction proceeds.
+/// When , the strategy compacts whenever there are groups older than the preserve window.
+/// Use for common trigger conditions such as token thresholds.
///
///
-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.";
+ ///
+ /// The default summarization prompt used when none is provided.
+ ///
+ public const string DefaultSummarizationPrompt =
+ """
+ You are a conversation summarizer. Produce a concise summary of the conversation that preserves:
+
+ - Key facts, decisions, and user preferences
+ - Important context needed for future turns
+ - Tool call outcomes and their significance
+
+ Omit pleasantries and redundant exchanges. Be factual and brief.
+ """;
///
/// Initializes a new instance of the class.
///
- /// The chat client to use for generating summaries.
- /// The maximum number of included groups allowed before summarization is triggered.
- /// Optional custom prompt for the summarization request. If , a default prompt is used.
- public SummarizationCompactionStrategy(IChatClient chatClient, int maxGroupsBeforeSummary, string? summarizationPrompt = null)
+ /// The to use for generating summaries. A smaller, faster model is recommended.
+ ///
+ /// The that controls when compaction proceeds.
+ ///
+ ///
+ /// The number of most-recent non-system message groups to protect from summarization.
+ /// Defaults to 4, preserving the current and recent exchanges.
+ ///
+ ///
+ /// An optional custom system prompt for the summarization LLM call. When ,
+ /// is used.
+ ///
+ 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; }
///
- /// 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.
///
- public int MaxGroupsBeforeSummary { get; }
+ public int PreserveRecentGroups { get; }
///
/// Gets the prompt used when requesting summaries from the chat client.
@@ -58,25 +86,35 @@ public sealed class SummarizationCompactionStrategy : ICompactionStrategy
public string SummarizationPrompt { get; }
///
- public async Task CompactAsync(MessageIndex groups, CancellationToken cancellationToken = default)
+ protected override async Task 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;
diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/ToolResultCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/ToolResultCompactionStrategy.cs
new file mode 100644
index 0000000000..c37d7b7596
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI/Compaction/ToolResultCompactionStrategy.cs
@@ -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;
+
+///
+/// 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.
+///
+///
+///
+/// This is the gentlest compaction strategy — it does not remove any user messages or
+/// plain assistant responses. It only targets
+/// groups outside the protected recent window, replacing each multi-message group
+/// (assistant call + tool results) with a single assistant message like
+/// [Tool calls: get_weather, search_docs].
+///
+///
+/// The predicate controls when compaction proceeds.
+/// When , a default compound trigger of
+/// AND
+/// is used.
+///
+///
+public sealed class ToolResultCompactionStrategy : CompactionStrategy
+{
+ ///
+ /// The default number of most-recent non-system groups to protect from collapsing.
+ ///
+ public const int DefaultPreserveRecentGroups = 2;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ ///
+ /// The that controls when compaction proceeds.
+ ///
+ ///
+ /// The number of most-recent non-system message groups to protect from collapsing.
+ /// Defaults to , ensuring the current turn's tool interactions remain visible.
+ ///
+ public ToolResultCompactionStrategy(CompactionTrigger trigger, int preserveRecentGroups = DefaultPreserveRecentGroups)
+ : base(trigger)
+ {
+ this.PreserveRecentGroups = preserveRecentGroups;
+ }
+
+ ///
+ /// Gets the number of most-recent non-system groups to protect from collapsing.
+ ///
+ public int PreserveRecentGroups { get; }
+
+ ///
+ protected override Task ApplyCompactionAsync(MessageIndex index, CancellationToken cancellationToken)
+ {
+ // Identify protected groups: the N most-recent non-system, non-excluded groups
+ List 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 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 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);
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/TruncationCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/TruncationCompactionStrategy.cs
index 47d729be57..5bd9630b77 100644
--- a/dotnet/src/Microsoft.Agents.AI/Compaction/TruncationCompactionStrategy.cs
+++ b/dotnet/src/Microsoft.Agents.AI/Compaction/TruncationCompactionStrategy.cs
@@ -6,72 +6,82 @@ using System.Threading.Tasks;
namespace Microsoft.Agents.AI.Compaction;
///
-/// 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 .
///
///
///
-/// This strategy implements a sliding window approach: it marks older groups as excluded
-/// while keeping the most recent groups within the configured 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.
///
///
-/// This strategy respects atomic group preservation — tool call groups (assistant message + tool results)
-/// are always kept or excluded together.
+/// The controls when compaction proceeds.
+/// Use for common trigger conditions such as token or group thresholds.
///
///
-public sealed class TruncationCompactionStrategy : ICompactionStrategy
+public sealed class TruncationCompactionStrategy : CompactionStrategy
{
+ ///
+ /// The default number of most-recent non-system groups to protect from collapsing.
+ ///
+ public const int DefaultPreserveRecentGroups = 32;
+
///
/// Initializes a new instance of the class.
///
- /// The maximum number of message groups to keep. Must be greater than zero.
- /// Whether to preserve system message groups regardless of position. Defaults to .
- public TruncationCompactionStrategy(int maxGroups, bool preserveSystemMessages = true)
+ ///
+ /// The that controls when compaction proceeds.
+ ///
+ ///
+ /// The minimum number of most-recent non-system message groups to keep.
+ /// Defaults to 1 so that at least the latest exchange is always preserved.
+ ///
+ public TruncationCompactionStrategy(CompactionTrigger trigger, int preserveRecentGroups = DefaultPreserveRecentGroups)
+ : base(trigger)
{
- this.MaxGroups = maxGroups;
- this.PreserveSystemMessages = preserveSystemMessages;
+ this.PreserveRecentGroups = preserveRecentGroups;
}
///
- /// 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.
///
- public int MaxGroups { get; }
-
- ///
- /// Gets a value indicating whether system message groups are preserved regardless of their position in the conversation.
- ///
- public bool PreserveSystemMessages { get; }
+ public int PreserveRecentGroups { get; }
///
- public Task CompactAsync(MessageIndex groups, CancellationToken cancellationToken = default)
+ protected override Task 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;
}
diff --git a/dotnet/src/Microsoft.Agents.AI/Microsoft.Agents.AI.csproj b/dotnet/src/Microsoft.Agents.AI/Microsoft.Agents.AI.csproj
index f036812900..93b228d29e 100644
--- a/dotnet/src/Microsoft.Agents.AI/Microsoft.Agents.AI.csproj
+++ b/dotnet/src/Microsoft.Agents.AI/Microsoft.Agents.AI.csproj
@@ -18,10 +18,14 @@
+
+
+
+
@@ -36,7 +40,7 @@
-
+
diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/PipelineCompactionStrategyTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/PipelineCompactionStrategyTests.cs
deleted file mode 100644
index c6a842dea4..0000000000
--- a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/PipelineCompactionStrategyTests.cs
+++ /dev/null
@@ -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;
-
-///
-/// Contains tests for the class.
-///
-public class PipelineCompactionStrategyTests
-{
- [Fact]
- public async Task CompactAsync_ExecutesAllStrategiesInOrder()
- {
- // Arrange
- List executionOrder = [];
- Mock strategy1 = new();
- strategy1.Setup(s => s.CompactAsync(It.IsAny(), It.IsAny()))
- .Callback(() => executionOrder.Add("first"))
- .ReturnsAsync(false);
-
- Mock strategy2 = new();
- strategy2.Setup(s => s.CompactAsync(It.IsAny(), It.IsAny()))
- .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 strategy1 = new();
- strategy1.Setup(s => s.CompactAsync(It.IsAny(), It.IsAny()))
- .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 strategy1 = new();
- strategy1.Setup(s => s.CompactAsync(It.IsAny(), It.IsAny()))
- .ReturnsAsync(false);
-
- Mock strategy2 = new();
- strategy2.Setup(s => s.CompactAsync(It.IsAny(), It.IsAny()))
- .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 strategy1 = new();
- strategy1.Setup(s => s.CompactAsync(It.IsAny(), It.IsAny()))
- .ReturnsAsync(true);
-
- Mock strategy2 = new();
- strategy2.Setup(s => s.CompactAsync(It.IsAny(), It.IsAny()))
- .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(), It.IsAny()), Times.Once);
- strategy2.Verify(s => s.CompactAsync(It.IsAny(), It.IsAny()), Times.Once);
- }
-
- [Fact]
- public async Task CompactAsync_StopsEarly_WhenTargetReached()
- {
- // Arrange — first strategy reduces to target
- Mock strategy1 = new();
- strategy1.Setup(s => s.CompactAsync(It.IsAny(), It.IsAny()))
- .Callback((groups, _) =>
- {
- // Exclude the first group to bring count down
- groups.Groups[0].IsExcluded = true;
- })
- .ReturnsAsync(true);
-
- Mock strategy2 = new();
- strategy2.Setup(s => s.CompactAsync(It.IsAny(), It.IsAny()))
- .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(), It.IsAny()), Times.Once);
- strategy2.Verify(s => s.CompactAsync(It.IsAny(), It.IsAny()), Times.Never);
- }
-
- [Fact]
- public async Task CompactAsync_DoesNotStopEarly_WhenTargetNotReached()
- {
- // Arrange — first strategy does NOT bring count to target
- Mock strategy1 = new();
- strategy1.Setup(s => s.CompactAsync(It.IsAny(), It.IsAny()))
- .ReturnsAsync(false);
-
- Mock strategy2 = new();
- strategy2.Setup(s => s.CompactAsync(It.IsAny(), It.IsAny()))
- .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(), It.IsAny()), Times.Once);
- strategy2.Verify(s => s.CompactAsync(It.IsAny(), It.IsAny()), Times.Once);
- }
-
- [Fact]
- public async Task CompactAsync_EarlyStopIgnored_WhenNoTargetSet()
- {
- // Arrange
- Mock strategy1 = new();
- strategy1.Setup(s => s.CompactAsync(It.IsAny(), It.IsAny()))
- .ReturnsAsync(true);
-
- Mock strategy2 = new();
- strategy2.Setup(s => s.CompactAsync(It.IsAny(), It.IsAny()))
- .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(), It.IsAny()), Times.Once);
- strategy2.Verify(s => s.CompactAsync(It.IsAny(), It.IsAny()), Times.Once);
- }
-
- [Fact]
- public async Task CompactAsync_ComposesStrategies_EndToEnd()
- {
- // Arrange — pipeline: first exclude oldest 2 non-system groups, then exclude 2 more
- Mock phase1 = new();
- phase1.Setup(s => s.CompactAsync(It.IsAny(), It.IsAny()))
- .Callback((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 phase2 = new();
- phase2.Setup(s => s.CompactAsync(It.IsAny(), It.IsAny()))
- .Callback((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 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(), It.IsAny()), Times.Once);
- phase2.Verify(s => s.CompactAsync(It.IsAny(), It.IsAny()), Times.Once);
- }
-
- [Fact]
- public async Task CompactAsync_EmptyPipeline_ReturnsFalseAsync()
- {
- // Arrange
- PipelineCompactionStrategy pipeline = new(new List());
- MessageIndex groups = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]);
-
- // Act
- bool result = await pipeline.CompactAsync(groups);
-
- // Assert
- Assert.False(result);
- }
-}
diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionTriggersTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionTriggersTests.cs
new file mode 100644
index 0000000000..cd85ff3f01
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionTriggersTests.cs
@@ -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;
+
+///
+/// Contains tests for and .
+///
+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));
+ }
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/InMemoryChatHistoryProviderCompactionTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/InMemoryChatHistoryProviderCompactionTests.cs
similarity index 99%
rename from dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/InMemoryChatHistoryProviderCompactionTests.cs
rename to dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/InMemoryChatHistoryProviderCompactionTests.cs
index d9155e90f0..95cfb88aaa 100644
--- a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/InMemoryChatHistoryProviderCompactionTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/InMemoryChatHistoryProviderCompactionTests.cs
@@ -8,7 +8,7 @@
//using Microsoft.Extensions.AI;
//using Moq;
-//namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction;
+//namespace Microsoft.Agents.AI.UnitTests.Compaction;
/////
///// Contains tests for the compaction integration with .
diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/MessageIndexTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/MessageIndexTests.cs
similarity index 99%
rename from dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/MessageIndexTests.cs
rename to dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/MessageIndexTests.cs
index 5ded224c52..bb84fdd930 100644
--- a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/MessageIndexTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/MessageIndexTests.cs
@@ -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;
///
/// Contains tests for the class.
diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/PipelineCompactionStrategyTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/PipelineCompactionStrategyTests.cs
new file mode 100644
index 0000000000..ddeb9129d6
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/PipelineCompactionStrategyTests.cs
@@ -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;
+
+///
+/// Contains tests for the class.
+///
+public class PipelineCompactionStrategyTests
+{
+ [Fact]
+ public async Task CompactAsync_ExecutesAllStrategiesInOrderAsync()
+ {
+ // Arrange
+ List 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 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());
+ MessageIndex groups = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]);
+
+ // Act
+ bool result = await pipeline.CompactAsync(groups);
+
+ // Assert
+ Assert.False(result);
+ }
+
+ ///
+ /// A simple test implementation of that delegates to a synchronous callback.
+ ///
+ private sealed class TestCompactionStrategy : CompactionStrategy
+ {
+ private readonly Func _applyFunc;
+
+ public TestCompactionStrategy(Func applyFunc)
+ : base(CompactionTriggers.Always)
+ {
+ this._applyFunc = applyFunc;
+ }
+
+ public int ApplyCallCount { get; private set; }
+
+ protected override Task ApplyCompactionAsync(MessageIndex index, CancellationToken cancellationToken)
+ {
+ this.ApplyCallCount++;
+ return Task.FromResult(this._applyFunc(index));
+ }
+ }
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/SlidingWindowCompactionStrategyTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/SlidingWindowCompactionStrategyTests.cs
new file mode 100644
index 0000000000..8a16aa3f21
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/SlidingWindowCompactionStrategyTests.cs
@@ -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;
+
+///
+/// Contains tests for the class.
+///
+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 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);
+ }
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/ToolResultCompactionStrategyTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/ToolResultCompactionStrategyTests.cs
new file mode 100644
index 0000000000..7fc17e8005
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/ToolResultCompactionStrategyTests.cs
@@ -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;
+
+///
+/// Contains tests for the class.
+///
+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 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 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 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);
+ }
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/TruncationCompactionStrategyTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/TruncationCompactionStrategyTests.cs
index 7b24966728..6f246a9987 100644
--- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/TruncationCompactionStrategyTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/TruncationCompactionStrategyTests.cs
@@ -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;
///
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);
+ }
}