diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionStrategy.cs
index 2bc1e7abd8..dc8ece399c 100644
--- a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionStrategy.cs
+++ b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionStrategy.cs
@@ -23,6 +23,12 @@ namespace Microsoft.Agents.AI.Compaction;
/// the trigger returns .
///
///
+/// An optional target condition controls when compaction stops. Strategies incrementally exclude
+/// groups and re-evaluate the target after each exclusion, stopping as soon as the target returns
+/// . When no target is specified, it defaults to the inverse of the trigger —
+/// meaning compaction stops when the trigger condition would no longer fire.
+///
+///
/// Strategies can be applied at three lifecycle points:
///
/// - In-run: During the tool loop, before each LLM call, to keep context within token limits.
@@ -43,9 +49,16 @@ public abstract class CompactionStrategy
///
/// The that determines whether compaction should proceed.
///
- protected CompactionStrategy(CompactionTrigger trigger)
+ ///
+ /// An optional target condition that controls when compaction stops. Strategies re-evaluate
+ /// this predicate after each incremental exclusion and stop when it returns .
+ /// When , defaults to the inverse of the — compaction
+ /// stops as soon as the trigger condition would no longer fire.
+ ///
+ protected CompactionStrategy(CompactionTrigger trigger, CompactionTrigger? target = null)
{
this.Trigger = Throw.IfNull(trigger);
+ this.Target = target ?? (index => !trigger(index));
}
///
@@ -53,6 +66,12 @@ public abstract class CompactionStrategy
///
protected CompactionTrigger Trigger { get; }
+ ///
+ /// Gets the target predicate that controls when compaction stops.
+ /// Strategies re-evaluate this after each incremental exclusion and stop when it returns .
+ ///
+ protected CompactionTrigger Target { get; }
+
///
/// Evaluates the and, when it fires, delegates to
/// and reports compaction metrics.
@@ -98,7 +117,8 @@ public abstract class CompactionStrategy
///
/// 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.
+ /// report metrics — the base class handles both. Implementations should use
+ /// to determine when to stop compacting incrementally.
///
/// The message index to compact. The strategy mutates this collection in place.
/// The to monitor for cancellation requests.
diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionTriggers.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionTriggers.cs
index d3eb350ca6..3b32eae376 100644
--- a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionTriggers.cs
+++ b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionTriggers.cs
@@ -20,6 +20,14 @@ public static class CompactionTriggers
public static readonly CompactionTrigger Always =
_ => true;
+ ///
+ /// Creates a trigger that fires when the included token count is below the specified maximum.
+ ///
+ /// The token threshold. Compaction proceeds when included tokens exceed this value.
+ /// A that evaluates included token count.
+ public static CompactionTrigger TokensBelow(int maxTokens) =>
+ index => index.IncludedTokenCount < maxTokens;
+
///
/// Creates a trigger that fires when the included token count exceeds the specified maximum.
///
diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/SlidingWindowCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/SlidingWindowCompactionStrategy.cs
index accf974963..a811da19f7 100644
--- a/dotnet/src/Microsoft.Agents.AI/Compaction/SlidingWindowCompactionStrategy.cs
+++ b/dotnet/src/Microsoft.Agents.AI/Compaction/SlidingWindowCompactionStrategy.cs
@@ -1,7 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
-using System.Linq;
using System.Threading;
using System.Threading.Tasks;
@@ -42,8 +41,12 @@ public sealed class SlidingWindowCompactionStrategy : CompactionStrategy
///
/// 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))
+ ///
+ /// An optional target condition that controls when compaction stops. When ,
+ /// defaults to the inverse of the auto-derived trigger — compaction stops as soon as the turn count is within bounds.
+ ///
+ public SlidingWindowCompactionStrategy(int maximumTurns = DefaultMaximumTurns, CompactionTrigger? target = null)
+ : base(CompactionTriggers.TurnsExceed(maximumTurns), target)
{
this.MaxTurns = maximumTurns;
}
@@ -71,24 +74,30 @@ public sealed class SlidingWindowCompactionStrategy : CompactionStrategy
return Task.FromResult(false);
}
- // Determine which turn indices to exclude (oldest)
+ // Exclude one turn at a time from oldest, re-checking target after each
int turnsToRemove = includedTurns.Count - this.MaxTurns;
- HashSet excludedTurnIndices = [.. includedTurns.Take(turnsToRemove)];
-
bool compacted = false;
- for (int i = 0; i < index.Groups.Count; i++)
+
+ for (int t = 0; t < turnsToRemove; t++)
{
- MessageGroup group = index.Groups[i];
- if (group.IsExcluded || group.Kind == MessageGroupKind.System)
+ int turnToExclude = includedTurns[t];
+
+ for (int i = 0; i < index.Groups.Count; i++)
{
- continue;
+ MessageGroup group = index.Groups[i];
+ if (!group.IsExcluded && group.Kind != MessageGroupKind.System && group.TurnIndex == turnToExclude)
+ {
+ group.IsExcluded = true;
+ group.ExcludeReason = $"Excluded by {nameof(SlidingWindowCompactionStrategy)}";
+ }
}
- if (group.TurnIndex is int ti && excludedTurnIndices.Contains(ti))
+ compacted = true;
+
+ // Stop when target condition is met
+ if (this.Target(index))
{
- group.IsExcluded = true;
- group.ExcludeReason = $"Excluded by {nameof(SlidingWindowCompactionStrategy)}";
- compacted = true;
+ break;
}
}
diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/SummarizationCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/SummarizationCompactionStrategy.cs
index c995783d23..3ec645e372 100644
--- a/dotnet/src/Microsoft.Agents.AI/Compaction/SummarizationCompactionStrategy.cs
+++ b/dotnet/src/Microsoft.Agents.AI/Compaction/SummarizationCompactionStrategy.cs
@@ -58,12 +58,17 @@ public sealed class SummarizationCompactionStrategy : CompactionStrategy
/// An optional custom system prompt for the summarization LLM call. When ,
/// is used.
///
+ ///
+ /// An optional target condition that controls when compaction stops. When ,
+ /// defaults to the inverse of the — compaction stops as soon as the trigger would no longer fire.
+ ///
public SummarizationCompactionStrategy(
IChatClient chatClient,
CompactionTrigger trigger,
int preserveRecentGroups = 4,
- string? summarizationPrompt = null)
- : base(trigger)
+ string? summarizationPrompt = null,
+ CompactionTrigger? target = null)
+ : base(trigger, target)
{
this.ChatClient = Throw.IfNull(chatClient);
this.PreserveRecentGroups = preserveRecentGroups;
@@ -100,19 +105,19 @@ public sealed class SummarizationCompactionStrategy : CompactionStrategy
}
int protectedFromEnd = Math.Min(this.PreserveRecentGroups, nonSystemIncludedCount);
- int groupsToSummarize = nonSystemIncludedCount - protectedFromEnd;
+ int maxSummarizable = nonSystemIncludedCount - protectedFromEnd;
- if (groupsToSummarize <= 0)
+ if (maxSummarizable <= 0)
{
return false;
}
- // Collect the oldest non-system included groups for summarization
+ // Mark oldest non-system groups for summarization one at a time until the target is met
StringBuilder conversationText = new();
int summarized = 0;
int insertIndex = -1;
- for (int i = 0; i < index.Groups.Count && summarized < groupsToSummarize; i++)
+ for (int i = 0; i < index.Groups.Count && summarized < maxSummarizable; i++)
{
MessageGroup group = index.Groups[i];
if (group.IsExcluded || group.Kind == MessageGroupKind.System)
@@ -138,6 +143,12 @@ public sealed class SummarizationCompactionStrategy : CompactionStrategy
group.IsExcluded = true;
group.ExcludeReason = $"Summarized by {nameof(SummarizationCompactionStrategy)}";
summarized++;
+
+ // Stop marking when target condition is met
+ if (this.Target(index))
+ {
+ break;
+ }
}
if (summarized == 0)
@@ -145,7 +156,7 @@ public sealed class SummarizationCompactionStrategy : CompactionStrategy
return false;
}
- // Generate summary using the chat client
+ // Generate summary using the chat client (single LLM call for all marked groups)
ChatResponse response = await this.ChatClient.GetResponseAsync(
[
new ChatMessage(ChatRole.System, this.SummarizationPrompt),
diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/ToolResultCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/ToolResultCompactionStrategy.cs
index c37d7b7596..8692e47159 100644
--- a/dotnet/src/Microsoft.Agents.AI/Compaction/ToolResultCompactionStrategy.cs
+++ b/dotnet/src/Microsoft.Agents.AI/Compaction/ToolResultCompactionStrategy.cs
@@ -44,8 +44,12 @@ public sealed class ToolResultCompactionStrategy : CompactionStrategy
/// 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)
+ ///
+ /// An optional target condition that controls when compaction stops. When ,
+ /// defaults to the inverse of the — compaction stops as soon as the trigger would no longer fire.
+ ///
+ public ToolResultCompactionStrategy(CompactionTrigger trigger, int preserveRecentGroups = DefaultPreserveRecentGroups, CompactionTrigger? target = null)
+ : base(trigger, target)
{
this.PreserveRecentGroups = preserveRecentGroups;
}
@@ -76,15 +80,30 @@ public sealed class ToolResultCompactionStrategy : CompactionStrategy
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--)
+ // Collect eligible tool groups in order (oldest first)
+ List eligibleIndices = [];
+ for (int i = 0; i < index.Groups.Count; i++)
{
MessageGroup group = index.Groups[i];
- if (group.IsExcluded || group.Kind != MessageGroupKind.ToolCall || protectedGroupIndices.Contains(i))
+ if (!group.IsExcluded && group.Kind == MessageGroupKind.ToolCall && !protectedGroupIndices.Contains(i))
{
- continue;
+ eligibleIndices.Add(i);
}
+ }
+
+ if (eligibleIndices.Count == 0)
+ {
+ return Task.FromResult(false);
+ }
+
+ // Collapse one tool group at a time from oldest, re-checking target after each
+ bool compacted = false;
+ int offset = 0;
+
+ for (int e = 0; e < eligibleIndices.Count; e++)
+ {
+ int idx = eligibleIndices[e] + offset;
+ MessageGroup group = index.Groups[idx];
// Extract tool names from FunctionCallContent
List toolNames = [];
@@ -107,9 +126,16 @@ public sealed class ToolResultCompactionStrategy : CompactionStrategy
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);
+ index.InsertGroup(idx + 1, MessageGroupKind.AssistantText, [new ChatMessage(ChatRole.Assistant, summary)], group.TurnIndex);
+ offset++; // Each insertion shifts subsequent indices by 1
compacted = true;
+
+ // Stop when target condition is met
+ if (this.Target(index))
+ {
+ break;
+ }
}
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 5bd9630b77..57a0eea528 100644
--- a/dotnet/src/Microsoft.Agents.AI/Compaction/TruncationCompactionStrategy.cs
+++ b/dotnet/src/Microsoft.Agents.AI/Compaction/TruncationCompactionStrategy.cs
@@ -37,8 +37,12 @@ public sealed class TruncationCompactionStrategy : CompactionStrategy
/// 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)
+ ///
+ /// An optional target condition that controls when compaction stops. When ,
+ /// defaults to the inverse of the — compaction stops as soon as the trigger would no longer fire.
+ ///
+ public TruncationCompactionStrategy(CompactionTrigger trigger, int preserveRecentGroups = DefaultPreserveRecentGroups, CompactionTrigger? target = null)
+ : base(trigger, target)
{
this.PreserveRecentGroups = preserveRecentGroups;
}
@@ -68,7 +72,7 @@ public sealed class TruncationCompactionStrategy : CompactionStrategy
return Task.FromResult(false);
}
- // Exclude oldest non-system groups first (iterate from the beginning)
+ // Exclude oldest non-system groups one at a time, re-checking target after each
bool compacted = false;
int removed = 0;
for (int i = 0; i < index.Groups.Count && removed < maxRemovable; i++)
@@ -83,6 +87,12 @@ public sealed class TruncationCompactionStrategy : CompactionStrategy
group.ExcludeReason = $"Truncated by {nameof(TruncationCompactionStrategy)}";
removed++;
compacted = true;
+
+ // Stop when target condition is met
+ if (this.Target(index))
+ {
+ break;
+ }
}
return Task.FromResult(compacted);
diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/TruncationCompactionStrategyTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/TruncationCompactionStrategyTests.cs
index 6f246a9987..73cb8d6783 100644
--- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/TruncationCompactionStrategyTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/TruncationCompactionStrategyTests.cs
@@ -75,13 +75,13 @@ public class TruncationCompactionStrategyTests
// Act
bool result = await strategy.CompactAsync(groups);
- // Assert
+ // Assert — incremental: excludes until GroupsExceed(2) is no longer met → 2 groups remain
Assert.True(result);
- Assert.Equal(1, groups.IncludedGroupCount);
- // Oldest 3 excluded, newest 1 kept
+ Assert.Equal(2, groups.IncludedGroupCount);
+ // Oldest 2 excluded, newest 2 kept
Assert.True(groups.Groups[0].IsExcluded);
Assert.True(groups.Groups[1].IsExcluded);
- Assert.True(groups.Groups[2].IsExcluded);
+ Assert.False(groups.Groups[2].IsExcluded);
Assert.False(groups.Groups[3].IsExcluded);
}