diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionApproach.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionApproach.cs index 1b9a23cc55..cfbab7bd41 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionApproach.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionApproach.cs @@ -17,21 +17,18 @@ public enum CompactionApproach /// /// Applies the lightest available compaction techniques. /// Collapses old tool call groups into concise summaries and uses truncation as an emergency backstop. - /// Does not require a summarization . /// Gentle, /// /// Balances context preservation with compaction efficiency. /// Applies tool result collapsing, LLM-based summarization, and truncation as an emergency backstop. - /// Requires a summarization . /// Balanced, /// /// Applies the most aggressive available compaction techniques. /// Applies tool result collapsing, LLM-based summarization, turn-based sliding window, and truncation. - /// Requires a summarization . /// Aggressive, } diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionStrategy.Factory.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionStrategy.Factory.cs index 788cfd39ad..2a63e0e19f 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionStrategy.Factory.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionStrategy.Factory.cs @@ -2,7 +2,6 @@ using System; using Microsoft.Extensions.AI; -using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.AI.Compaction; @@ -62,16 +61,12 @@ public abstract partial class CompactionStrategy /// /// /// The compaction approach that controls which strategy or pipeline to use. - /// does not require a ; - /// and require one. /// /// /// The context-size profile that controls token and message thresholds. /// /// /// The used for LLM-based summarization. - /// Required when is or - /// ; ignored for . /// /// A configured for the specified approach and size. /// @@ -80,21 +75,16 @@ public abstract partial class CompactionStrategy /// /// or is not a defined enum value. /// - public static CompactionStrategy Create(CompactionApproach approach, CompactionSize size, IChatClient? chatClient = null) + public static CompactionStrategy Create(CompactionApproach approach, CompactionSize size, IChatClient chatClient) { - if (approach is CompactionApproach.Balanced or CompactionApproach.Aggressive) - { - _ = Throw.IfNull(chatClient); - } - int tokenLimit = GetTokenLimit(size); int messageLimit = GetMessageLimit(size); return approach switch { - CompactionApproach.Gentle => CreateGentlePipeline(tokenLimit, messageLimit), - CompactionApproach.Balanced => CreateBalancedPipeline(tokenLimit, messageLimit, chatClient!), - CompactionApproach.Aggressive => CreateAggressivePipeline(tokenLimit, messageLimit, GetTurnLimit(size), chatClient!), + CompactionApproach.Gentle => CreateGentlePipeline(tokenLimit, messageLimit, chatClient), + CompactionApproach.Balanced => CreateBalancedPipeline(tokenLimit, messageLimit, chatClient), + CompactionApproach.Aggressive => CreateAggressivePipeline(tokenLimit, messageLimit, GetTurnLimit(size), chatClient), _ => throw new ArgumentOutOfRangeException(nameof(approach), approach, null), }; } @@ -123,35 +113,59 @@ public abstract partial class CompactionStrategy _ => throw new ArgumentOutOfRangeException(nameof(size), size, null), }; - private static PipelineCompactionStrategy CreateGentlePipeline(int tokenLimit, int messageLimit) => - new( - new ToolResultCompactionStrategy(CompactionTriggers.MessagesExceed(messageLimit)), - new TruncationCompactionStrategy(CompactionTriggers.TokensExceed(tokenLimit))); + private static PipelineCompactionStrategy CreateGentlePipeline(int tokenLimit, int messageLimit, IChatClient chatClient) + { + int messageTarget = messageLimit * 4 / 5; + int tokenTarget = tokenLimit * 4 / 5; + + return new( + new ToolResultCompactionStrategy( + trigger: CompactionTriggers.MessagesExceed(messageLimit), + target: CompactionTriggers.MessagesBelow(messageTarget)), + new SummarizationCompactionStrategy( + chatClient, + trigger: CompactionTriggers.TokensExceed(tokenLimit), + target: CompactionTriggers.TokensBelow(tokenTarget))); + } private static PipelineCompactionStrategy CreateBalancedPipeline(int tokenLimit, int messageLimit, IChatClient chatClient) { - // Early stages trigger at two-thirds of the limit so the pipeline has room to compact - // incrementally before reaching the emergency truncation backstop at the full limit. - int earlyMessageTrigger = messageLimit * 2 / 3; - int earlyTokenTrigger = tokenLimit * 2 / 3; + int messageTarget = messageLimit * 3 / 4; + int tokenTarget = tokenLimit * 4 / 5; return new( - new ToolResultCompactionStrategy(CompactionTriggers.MessagesExceed(earlyMessageTrigger)), - new SummarizationCompactionStrategy(chatClient, CompactionTriggers.TokensExceed(earlyTokenTrigger)), - new TruncationCompactionStrategy(CompactionTriggers.TokensExceed(tokenLimit))); + new ToolResultCompactionStrategy( + trigger: CompactionTriggers.MessagesExceed(messageLimit), + target: CompactionTriggers.MessagesBelow(messageTarget)), + new SummarizationCompactionStrategy( + chatClient, + trigger: CompactionTriggers.TokensExceed(tokenLimit), + target: CompactionTriggers.TokensBelow(tokenTarget)), + new TruncationCompactionStrategy( + trigger: CompactionTriggers.TokensExceed(tokenLimit), + target: CompactionTriggers.TokensBelow(tokenTarget))); } private static PipelineCompactionStrategy CreateAggressivePipeline(int tokenLimit, int messageLimit, int turnLimit, IChatClient chatClient) { // Early stages trigger at half the limit so compaction kicks in sooner and // the sliding window and truncation backstop are reached less often. - int earlyMessageTrigger = messageLimit / 2; - int earlyTokenTrigger = tokenLimit / 2; + int messageTarget = messageLimit * 3 / 4; + int tokenTarget = tokenLimit * 3 / 4; return new( - new ToolResultCompactionStrategy(CompactionTriggers.MessagesExceed(earlyMessageTrigger)), - new SummarizationCompactionStrategy(chatClient, CompactionTriggers.TokensExceed(earlyTokenTrigger)), - new SlidingWindowCompactionStrategy(CompactionTriggers.TurnsExceed(turnLimit)), - new TruncationCompactionStrategy(CompactionTriggers.TokensExceed(tokenLimit))); + new ToolResultCompactionStrategy( + trigger: CompactionTriggers.MessagesExceed(messageLimit), + target: CompactionTriggers.MessagesBelow(messageTarget)), + new SummarizationCompactionStrategy( + chatClient, + trigger: CompactionTriggers.TokensExceed(tokenLimit), + target: CompactionTriggers.TokensBelow(tokenTarget)), + new SlidingWindowCompactionStrategy( + trigger: CompactionTriggers.TokensExceed(tokenLimit), + target: CompactionTriggers.TokensBelow(tokenTarget)), + new TruncationCompactionStrategy( + trigger: CompactionTriggers.TokensExceed(tokenLimit), + target: CompactionTriggers.TokensBelow(tokenTarget))); } } diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionTriggers.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionTriggers.cs index a2bc398ac3..9350b581af 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionTriggers.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionTriggers.cs @@ -50,6 +50,14 @@ public static class CompactionTriggers public static CompactionTrigger TokensExceed(int maxTokens) => index => index.IncludedTokenCount > maxTokens; + /// + /// Creates a trigger that fires when the included message count is below the specified maximum. + /// + /// The message threshold. + /// A that evaluates included message count. + public static CompactionTrigger MessagesBelow(int maxMessages) => + index => index.IncludedMessageCount < maxMessages; + /// /// Creates a trigger that fires when the included message count exceeds the specified maximum. /// diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionStrategyCreateTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionStrategyCreateTests.cs index 3ef88173e3..992b4ac2c0 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionStrategyCreateTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionStrategyCreateTests.cs @@ -33,11 +33,11 @@ public class CompactionStrategyCreateTests { PipelineCompactionStrategy pipeline = Assert.IsType( - CompactionStrategy.Create(CompactionApproach.Gentle, CompactionSize.Compact)); + CompactionStrategy.Create(CompactionApproach.Gentle, CompactionSize.Compact, CreateMockChatClient())); Assert.Equal(2, pipeline.Strategies.Count); Assert.IsType(pipeline.Strategies[0]); - Assert.IsType(pipeline.Strategies[1]); + Assert.IsType(pipeline.Strategies[1]); } [Fact] @@ -45,11 +45,11 @@ public class CompactionStrategyCreateTests { PipelineCompactionStrategy pipeline = Assert.IsType( - CompactionStrategy.Create(CompactionApproach.Gentle, CompactionSize.Moderate)); + CompactionStrategy.Create(CompactionApproach.Gentle, CompactionSize.Moderate, CreateMockChatClient())); Assert.Equal(2, pipeline.Strategies.Count); Assert.IsType(pipeline.Strategies[0]); - Assert.IsType(pipeline.Strategies[1]); + Assert.IsType(pipeline.Strategies[1]); } [Fact] @@ -57,18 +57,18 @@ public class CompactionStrategyCreateTests { PipelineCompactionStrategy pipeline = Assert.IsType( - CompactionStrategy.Create(CompactionApproach.Gentle, CompactionSize.Generous)); + CompactionStrategy.Create(CompactionApproach.Gentle, CompactionSize.Generous, CreateMockChatClient())); Assert.Equal(2, pipeline.Strategies.Count); Assert.IsType(pipeline.Strategies[0]); - Assert.IsType(pipeline.Strategies[1]); + Assert.IsType(pipeline.Strategies[1]); } [Fact] public void CreateGentleDoesNotRequireChatClient() { // No chatClient supplied — should succeed without throwing. - CompactionStrategy strategy = CompactionStrategy.Create(CompactionApproach.Gentle, CompactionSize.Moderate); + CompactionStrategy strategy = CompactionStrategy.Create(CompactionApproach.Gentle, CompactionSize.Moderate, CreateMockChatClient()); Assert.NotNull(strategy); } @@ -117,7 +117,7 @@ public class CompactionStrategyCreateTests public void CreateBalancedNullChatClientThrows() { Assert.Throws( - () => CompactionStrategy.Create(CompactionApproach.Balanced, CompactionSize.Moderate, null)); + () => CompactionStrategy.Create(CompactionApproach.Balanced, CompactionSize.Moderate, null!)); } // ── Aggressive ──────────────────────────────────────────────────────────── @@ -164,27 +164,20 @@ public class CompactionStrategyCreateTests Assert.IsType(pipeline.Strategies[3]); } - [Fact] - public void CreateAggressiveNullChatClientThrows() - { - Assert.Throws( - () => CompactionStrategy.Create(CompactionApproach.Aggressive, CompactionSize.Moderate, null)); - } - // ── Invalid enum values ─────────────────────────────────────────────────── [Fact] public void CreateInvalidApproachThrows() { Assert.Throws( - () => CompactionStrategy.Create((CompactionApproach)99, CompactionSize.Moderate)); + () => CompactionStrategy.Create((CompactionApproach)99, CompactionSize.Moderate, CreateMockChatClient())); } [Fact] public void CreateInvalidSizeThrows() { Assert.Throws( - () => CompactionStrategy.Create(CompactionApproach.Gentle, (CompactionSize)99)); + () => CompactionStrategy.Create(CompactionApproach.Gentle, (CompactionSize)99, CreateMockChatClient())); } // ── Size-threshold behavioral verification ──────────────────────────────── @@ -203,8 +196,8 @@ public class CompactionStrategyCreateTests // The configuration preserves a number of most-recent groups, leaving the oldest // tool-call group eligible for collapsing, which makes the behavioral difference // between Compact and Moderate sizes observable in this test. - CompactionStrategy compactPipeline = CompactionStrategy.Create(CompactionApproach.Gentle, CompactionSize.Compact); - CompactionStrategy moderatePipeline = CompactionStrategy.Create(CompactionApproach.Gentle, CompactionSize.Moderate); + CompactionStrategy compactPipeline = CompactionStrategy.Create(CompactionApproach.Gentle, CompactionSize.Compact, CreateMockChatClient()); + CompactionStrategy moderatePipeline = CompactionStrategy.Create(CompactionApproach.Gentle, CompactionSize.Moderate, CreateMockChatClient()); List messages = [