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); }