From 74e75268bc3cffd7ca42b9722459ac99204f7954 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Mar 2026 23:42:13 +0000 Subject: [PATCH] Add CompactionApproach/CompactionSize enums and CompactionStrategy.Create() factory Co-authored-by: crickman <66376200+crickman@users.noreply.github.com> --- .../Compaction/CompactionApproach.cs | 35 +++ .../Compaction/CompactionSize.cs | 38 +++ .../Compaction/CompactionStrategy.cs | 114 +++++++++ .../CompactionStrategyCreateTests.cs | 237 ++++++++++++++++++ 4 files changed, 424 insertions(+) create mode 100644 dotnet/src/Microsoft.Agents.AI/Compaction/CompactionApproach.cs create mode 100644 dotnet/src/Microsoft.Agents.AI/Compaction/CompactionSize.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionStrategyCreateTests.cs diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionApproach.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionApproach.cs new file mode 100644 index 0000000000..4204a32c58 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionApproach.cs @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Agents.AI.Compaction; + +/// +/// Describes the compaction approach used by a pre-configured . +/// +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +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/CompactionSize.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionSize.cs new file mode 100644 index 0000000000..a71ff63cd2 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionSize.cs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Agents.AI.Compaction; + +/// +/// Describes the context-size profile used by a pre-configured . +/// +/// +/// The size profile controls the token and message thresholds at which compaction triggers. +/// Choose a size that matches the input token limit of your model: +/// for smaller context windows, for common mid-range models, +/// and for models with large context windows. +/// +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public enum CompactionSize +{ + /// + /// Targets models with smaller context windows (approximately 4,000 tokens). + /// Compaction triggers earlier and keeps less history in context. + /// + Compact, + + /// + /// Targets models with a medium-sized context window (approximately 8,000 tokens). + /// This is a reasonable default for most common models. + /// + Moderate, + + /// + /// Targets models with large context windows (approximately 16,000 tokens or more). + /// Compaction triggers later and retains more history in context. + /// + Generous, +} diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionStrategy.cs index e6f7485438..818908cd8e 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionStrategy.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionStrategy.cs @@ -5,6 +5,7 @@ using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Shared.DiagnosticIds; @@ -161,4 +162,117 @@ public abstract class CompactionStrategy /// The target value. /// 0 if negative; otherwise the value protected static int EnsureNonNegative(int value) => Math.Max(0, value); + + /// + /// Creates a pre-configured from a combination of + /// and . + /// + /// + /// + /// The controls which strategies are included in the pipeline: + /// + /// : tool result collapsing + truncation backstop. No required. + /// : tool result collapsing + LLM summarization + truncation backstop. + /// : tool result collapsing + LLM summarization + sliding window + truncation backstop. + /// + /// + /// + /// The controls the token and message thresholds at which each stage triggers. + /// Choose a size that matches the input token limit of your model. + /// + /// + /// + /// 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. + /// + /// is and requires one. + /// + /// + /// or is not a defined enum value. + /// + public static CompactionStrategy Create(CompactionApproach approach, CompactionSize size, IChatClient? chatClient = null) + { + 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!), + _ => throw new ArgumentOutOfRangeException(nameof(approach), approach, null), + }; + } + + private static int GetTokenLimit(CompactionSize size) => size switch + { + CompactionSize.Compact => 4_000, + CompactionSize.Moderate => 8_000, + CompactionSize.Generous => 16_000, + _ => throw new ArgumentOutOfRangeException(nameof(size), size, null), + }; + + private static int GetMessageLimit(CompactionSize size) => size switch + { + CompactionSize.Compact => 10, + CompactionSize.Moderate => 20, + CompactionSize.Generous => 40, + _ => throw new ArgumentOutOfRangeException(nameof(size), size, null), + }; + + private static int GetTurnLimit(CompactionSize size) => size switch + { + CompactionSize.Compact => 3, + CompactionSize.Moderate => 6, + CompactionSize.Generous => 12, + _ => 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 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; + + return new( + new ToolResultCompactionStrategy(CompactionTriggers.MessagesExceed(earlyMessageTrigger)), + new SummarizationCompactionStrategy(chatClient, CompactionTriggers.TokensExceed(earlyTokenTrigger)), + new TruncationCompactionStrategy(CompactionTriggers.TokensExceed(tokenLimit))); + } + + 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; + + return new( + new ToolResultCompactionStrategy(CompactionTriggers.MessagesExceed(earlyMessageTrigger)), + new SummarizationCompactionStrategy(chatClient, CompactionTriggers.TokensExceed(earlyTokenTrigger)), + new SlidingWindowCompactionStrategy(CompactionTriggers.TurnsExceed(turnLimit)), + new TruncationCompactionStrategy(CompactionTriggers.TokensExceed(tokenLimit))); + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionStrategyCreateTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionStrategyCreateTests.cs new file mode 100644 index 0000000000..8950c348af --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionStrategyCreateTests.cs @@ -0,0 +1,237 @@ +// 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; +using Moq; + +namespace Microsoft.Agents.AI.UnitTests.Compaction; + +/// +/// Contains tests for . +/// +public class CompactionStrategyCreateTests +{ + private static IChatClient CreateMockChatClient() + { + Mock mock = new(); + mock.Setup(c => c.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new ChatResponse([new ChatMessage(ChatRole.Assistant, "summary")])); + return mock.Object; + } + + // ── Gentle ──────────────────────────────────────────────────────────────── + + [Fact] + public void CreateGentleCompactReturnsTwoStrategyPipeline() + { + PipelineCompactionStrategy pipeline = + Assert.IsType( + CompactionStrategy.Create(CompactionApproach.Gentle, CompactionSize.Compact)); + + Assert.Equal(2, pipeline.Strategies.Count); + Assert.IsType(pipeline.Strategies[0]); + Assert.IsType(pipeline.Strategies[1]); + } + + [Fact] + public void CreateGentleModerateReturnsTwoStrategyPipeline() + { + PipelineCompactionStrategy pipeline = + Assert.IsType( + CompactionStrategy.Create(CompactionApproach.Gentle, CompactionSize.Moderate)); + + Assert.Equal(2, pipeline.Strategies.Count); + Assert.IsType(pipeline.Strategies[0]); + Assert.IsType(pipeline.Strategies[1]); + } + + [Fact] + public void CreateGentleGenerousReturnsTwoStrategyPipeline() + { + PipelineCompactionStrategy pipeline = + Assert.IsType( + CompactionStrategy.Create(CompactionApproach.Gentle, CompactionSize.Generous)); + + Assert.Equal(2, pipeline.Strategies.Count); + Assert.IsType(pipeline.Strategies[0]); + Assert.IsType(pipeline.Strategies[1]); + } + + [Fact] + public void CreateGentleDoesNotRequireChatClient() + { + // No chatClient supplied — should succeed without throwing. + CompactionStrategy strategy = CompactionStrategy.Create(CompactionApproach.Gentle, CompactionSize.Moderate); + Assert.NotNull(strategy); + } + + // ── Balanced ────────────────────────────────────────────────────────────── + + [Fact] + public void CreateBalancedCompactReturnsThreeStrategyPipeline() + { + PipelineCompactionStrategy pipeline = + Assert.IsType( + CompactionStrategy.Create(CompactionApproach.Balanced, CompactionSize.Compact, CreateMockChatClient())); + + Assert.Equal(3, pipeline.Strategies.Count); + Assert.IsType(pipeline.Strategies[0]); + Assert.IsType(pipeline.Strategies[1]); + Assert.IsType(pipeline.Strategies[2]); + } + + [Fact] + public void CreateBalancedModerateReturnsThreeStrategyPipeline() + { + PipelineCompactionStrategy pipeline = + Assert.IsType( + CompactionStrategy.Create(CompactionApproach.Balanced, CompactionSize.Moderate, CreateMockChatClient())); + + Assert.Equal(3, pipeline.Strategies.Count); + Assert.IsType(pipeline.Strategies[0]); + Assert.IsType(pipeline.Strategies[1]); + Assert.IsType(pipeline.Strategies[2]); + } + + [Fact] + public void CreateBalancedGenerousReturnsThreeStrategyPipeline() + { + PipelineCompactionStrategy pipeline = + Assert.IsType( + CompactionStrategy.Create(CompactionApproach.Balanced, CompactionSize.Generous, CreateMockChatClient())); + + Assert.Equal(3, pipeline.Strategies.Count); + Assert.IsType(pipeline.Strategies[0]); + Assert.IsType(pipeline.Strategies[1]); + Assert.IsType(pipeline.Strategies[2]); + } + + [Fact] + public void CreateBalancedNullChatClientThrows() + { + Assert.Throws( + () => CompactionStrategy.Create(CompactionApproach.Balanced, CompactionSize.Moderate, null)); + } + + // ── Aggressive ──────────────────────────────────────────────────────────── + + [Fact] + public void CreateAggressiveCompactReturnsFourStrategyPipeline() + { + PipelineCompactionStrategy pipeline = + Assert.IsType( + CompactionStrategy.Create(CompactionApproach.Aggressive, CompactionSize.Compact, CreateMockChatClient())); + + Assert.Equal(4, pipeline.Strategies.Count); + Assert.IsType(pipeline.Strategies[0]); + Assert.IsType(pipeline.Strategies[1]); + Assert.IsType(pipeline.Strategies[2]); + Assert.IsType(pipeline.Strategies[3]); + } + + [Fact] + public void CreateAggressiveModerateReturnsFourStrategyPipeline() + { + PipelineCompactionStrategy pipeline = + Assert.IsType( + CompactionStrategy.Create(CompactionApproach.Aggressive, CompactionSize.Moderate, CreateMockChatClient())); + + Assert.Equal(4, pipeline.Strategies.Count); + Assert.IsType(pipeline.Strategies[0]); + Assert.IsType(pipeline.Strategies[1]); + Assert.IsType(pipeline.Strategies[2]); + Assert.IsType(pipeline.Strategies[3]); + } + + [Fact] + public void CreateAggressiveGenerousReturnsFourStrategyPipeline() + { + PipelineCompactionStrategy pipeline = + Assert.IsType( + CompactionStrategy.Create(CompactionApproach.Aggressive, CompactionSize.Generous, CreateMockChatClient())); + + Assert.Equal(4, pipeline.Strategies.Count); + Assert.IsType(pipeline.Strategies[0]); + Assert.IsType(pipeline.Strategies[1]); + Assert.IsType(pipeline.Strategies[2]); + 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)); + } + + [Fact] + public void CreateInvalidSizeThrows() + { + Assert.Throws( + () => CompactionStrategy.Create(CompactionApproach.Gentle, (CompactionSize)99)); + } + + // ── Size-threshold behavioral verification ──────────────────────────────── + + /// + /// Verifies that and + /// configure different message thresholds: 18 messages (17 groups) should trigger the Compact + /// ToolResultCompaction stage (threshold 10) but NOT the Moderate one (threshold 20). + /// + [Fact] + public async Task CreateGentleSizeDifferentiatesMessageThresholdsAsync() + { + // Arrange: 17 groups = 1 ToolCall + 8 User + 8 AssistantText = 18 messages. + // Compact ToolResult triggers at MessagesExceed(10) → 18 > 10 → fires. + // Moderate ToolResult triggers at MessagesExceed(20) → 18 < 20 → does not fire. + // + // With minimumPreservedGroups=16 and 17 total groups the oldest 1 group (the tool-call + // group) is eligible for collapsing, making the behavioral difference observable. + CompactionStrategy compactPipeline = CompactionStrategy.Create(CompactionApproach.Gentle, CompactionSize.Compact); + CompactionStrategy moderatePipeline = CompactionStrategy.Create(CompactionApproach.Gentle, CompactionSize.Moderate); + + List messages = + [ + // 1 tool-call group (assistant FunctionCall + tool result = 2 messages, 1 group) + new(ChatRole.Assistant, [new FunctionCallContent("c1", "fetch")]), + new(ChatRole.Tool, "data"), + // 8 user/assistant pairs = 16 messages, 16 groups + new(ChatRole.User, "Q1"), new(ChatRole.Assistant, "A1"), + new(ChatRole.User, "Q2"), new(ChatRole.Assistant, "A2"), + new(ChatRole.User, "Q3"), new(ChatRole.Assistant, "A3"), + new(ChatRole.User, "Q4"), new(ChatRole.Assistant, "A4"), + new(ChatRole.User, "Q5"), new(ChatRole.Assistant, "A5"), + new(ChatRole.User, "Q6"), new(ChatRole.Assistant, "A6"), + new(ChatRole.User, "Q7"), new(ChatRole.Assistant, "A7"), + new(ChatRole.User, "Q8"), new(ChatRole.Assistant, "A8"), + ]; + + // Two separate indexes so strategies run independently. + CompactionMessageIndex compactIndex = CompactionMessageIndex.Create(messages); + CompactionMessageIndex moderateIndex = CompactionMessageIndex.Create(messages); + + // Act + bool compactCompacted = await compactPipeline.CompactAsync(compactIndex); + bool moderateCompacted = await moderatePipeline.CompactAsync(moderateIndex); + + // Assert + Assert.True(compactCompacted, "Compact size should trigger ToolResult compaction at 18 messages (> threshold of 10)."); + Assert.False(moderateCompacted, "Moderate size should NOT trigger ToolResult compaction at 18 messages (< threshold of 20)."); + } +}