Add CompactionApproach/CompactionSize enums and CompactionStrategy.Create() factory

Co-authored-by: crickman <66376200+crickman@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-03-12 23:42:13 +00:00
Unverified
parent 579993d165
commit 74e75268bc
4 changed files with 424 additions and 0 deletions
@@ -0,0 +1,35 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI.Compaction;
/// <summary>
/// Describes the compaction approach used by a pre-configured <see cref="CompactionStrategy"/>.
/// </summary>
/// <seealso cref="CompactionStrategy.Create"/>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public enum CompactionApproach
{
/// <summary>
/// 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 <see cref="Microsoft.Extensions.AI.IChatClient"/>.
/// </summary>
Gentle,
/// <summary>
/// Balances context preservation with compaction efficiency.
/// Applies tool result collapsing, LLM-based summarization, and truncation as an emergency backstop.
/// Requires a summarization <see cref="Microsoft.Extensions.AI.IChatClient"/>.
/// </summary>
Balanced,
/// <summary>
/// Applies the most aggressive available compaction techniques.
/// Applies tool result collapsing, LLM-based summarization, turn-based sliding window, and truncation.
/// Requires a summarization <see cref="Microsoft.Extensions.AI.IChatClient"/>.
/// </summary>
Aggressive,
}
@@ -0,0 +1,38 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI.Compaction;
/// <summary>
/// Describes the context-size profile used by a pre-configured <see cref="CompactionStrategy"/>.
/// </summary>
/// <remarks>
/// 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:
/// <see cref="Compact"/> for smaller context windows, <see cref="Moderate"/> for common mid-range models,
/// and <see cref="Generous"/> for models with large context windows.
/// </remarks>
/// <seealso cref="CompactionStrategy.Create"/>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public enum CompactionSize
{
/// <summary>
/// Targets models with smaller context windows (approximately 4,000 tokens).
/// Compaction triggers earlier and keeps less history in context.
/// </summary>
Compact,
/// <summary>
/// Targets models with a medium-sized context window (approximately 8,000 tokens).
/// This is a reasonable default for most common models.
/// </summary>
Moderate,
/// <summary>
/// Targets models with large context windows (approximately 16,000 tokens or more).
/// Compaction triggers later and retains more history in context.
/// </summary>
Generous,
}
@@ -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
/// <param name="value">The target value.</param>
/// <returns>0 if negative; otherwise the value</returns>
protected static int EnsureNonNegative(int value) => Math.Max(0, value);
/// <summary>
/// Creates a pre-configured <see cref="CompactionStrategy"/> from a combination of
/// <paramref name="approach"/> and <paramref name="size"/>.
/// </summary>
/// <remarks>
/// <para>
/// The <paramref name="approach"/> controls which strategies are included in the pipeline:
/// <list type="bullet">
/// <item><description><see cref="CompactionApproach.Gentle"/>: tool result collapsing + truncation backstop. No <paramref name="chatClient"/> required.</description></item>
/// <item><description><see cref="CompactionApproach.Balanced"/>: tool result collapsing + LLM summarization + truncation backstop.</description></item>
/// <item><description><see cref="CompactionApproach.Aggressive"/>: tool result collapsing + LLM summarization + sliding window + truncation backstop.</description></item>
/// </list>
/// </para>
/// <para>
/// The <paramref name="size"/> controls the token and message thresholds at which each stage triggers.
/// Choose a size that matches the input token limit of your model.
/// </para>
/// </remarks>
/// <param name="approach">
/// The compaction approach that controls which strategy or pipeline to use.
/// <see cref="CompactionApproach.Gentle"/> does not require a <paramref name="chatClient"/>;
/// <see cref="CompactionApproach.Balanced"/> and <see cref="CompactionApproach.Aggressive"/> require one.
/// </param>
/// <param name="size">
/// The context-size profile that controls token and message thresholds.
/// </param>
/// <param name="chatClient">
/// The <see cref="IChatClient"/> used for LLM-based summarization.
/// Required when <paramref name="approach"/> is <see cref="CompactionApproach.Balanced"/> or
/// <see cref="CompactionApproach.Aggressive"/>; ignored for <see cref="CompactionApproach.Gentle"/>.
/// </param>
/// <returns>A <see cref="CompactionStrategy"/> configured for the specified approach and size.</returns>
/// <exception cref="ArgumentNullException">
/// <paramref name="chatClient"/> is <see langword="null"/> and <paramref name="approach"/> requires one.
/// </exception>
/// <exception cref="ArgumentOutOfRangeException">
/// <paramref name="approach"/> or <paramref name="size"/> is not a defined enum value.
/// </exception>
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)));
}
}
@@ -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;
/// <summary>
/// Contains tests for <see cref="CompactionStrategy.Create"/>.
/// </summary>
public class CompactionStrategyCreateTests
{
private static IChatClient CreateMockChatClient()
{
Mock<IChatClient> mock = new();
mock.Setup(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new ChatResponse([new ChatMessage(ChatRole.Assistant, "summary")]));
return mock.Object;
}
// ── Gentle ────────────────────────────────────────────────────────────────
[Fact]
public void CreateGentleCompactReturnsTwoStrategyPipeline()
{
PipelineCompactionStrategy pipeline =
Assert.IsType<PipelineCompactionStrategy>(
CompactionStrategy.Create(CompactionApproach.Gentle, CompactionSize.Compact));
Assert.Equal(2, pipeline.Strategies.Count);
Assert.IsType<ToolResultCompactionStrategy>(pipeline.Strategies[0]);
Assert.IsType<TruncationCompactionStrategy>(pipeline.Strategies[1]);
}
[Fact]
public void CreateGentleModerateReturnsTwoStrategyPipeline()
{
PipelineCompactionStrategy pipeline =
Assert.IsType<PipelineCompactionStrategy>(
CompactionStrategy.Create(CompactionApproach.Gentle, CompactionSize.Moderate));
Assert.Equal(2, pipeline.Strategies.Count);
Assert.IsType<ToolResultCompactionStrategy>(pipeline.Strategies[0]);
Assert.IsType<TruncationCompactionStrategy>(pipeline.Strategies[1]);
}
[Fact]
public void CreateGentleGenerousReturnsTwoStrategyPipeline()
{
PipelineCompactionStrategy pipeline =
Assert.IsType<PipelineCompactionStrategy>(
CompactionStrategy.Create(CompactionApproach.Gentle, CompactionSize.Generous));
Assert.Equal(2, pipeline.Strategies.Count);
Assert.IsType<ToolResultCompactionStrategy>(pipeline.Strategies[0]);
Assert.IsType<TruncationCompactionStrategy>(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<PipelineCompactionStrategy>(
CompactionStrategy.Create(CompactionApproach.Balanced, CompactionSize.Compact, CreateMockChatClient()));
Assert.Equal(3, pipeline.Strategies.Count);
Assert.IsType<ToolResultCompactionStrategy>(pipeline.Strategies[0]);
Assert.IsType<SummarizationCompactionStrategy>(pipeline.Strategies[1]);
Assert.IsType<TruncationCompactionStrategy>(pipeline.Strategies[2]);
}
[Fact]
public void CreateBalancedModerateReturnsThreeStrategyPipeline()
{
PipelineCompactionStrategy pipeline =
Assert.IsType<PipelineCompactionStrategy>(
CompactionStrategy.Create(CompactionApproach.Balanced, CompactionSize.Moderate, CreateMockChatClient()));
Assert.Equal(3, pipeline.Strategies.Count);
Assert.IsType<ToolResultCompactionStrategy>(pipeline.Strategies[0]);
Assert.IsType<SummarizationCompactionStrategy>(pipeline.Strategies[1]);
Assert.IsType<TruncationCompactionStrategy>(pipeline.Strategies[2]);
}
[Fact]
public void CreateBalancedGenerousReturnsThreeStrategyPipeline()
{
PipelineCompactionStrategy pipeline =
Assert.IsType<PipelineCompactionStrategy>(
CompactionStrategy.Create(CompactionApproach.Balanced, CompactionSize.Generous, CreateMockChatClient()));
Assert.Equal(3, pipeline.Strategies.Count);
Assert.IsType<ToolResultCompactionStrategy>(pipeline.Strategies[0]);
Assert.IsType<SummarizationCompactionStrategy>(pipeline.Strategies[1]);
Assert.IsType<TruncationCompactionStrategy>(pipeline.Strategies[2]);
}
[Fact]
public void CreateBalancedNullChatClientThrows()
{
Assert.Throws<ArgumentNullException>(
() => CompactionStrategy.Create(CompactionApproach.Balanced, CompactionSize.Moderate, null));
}
// ── Aggressive ────────────────────────────────────────────────────────────
[Fact]
public void CreateAggressiveCompactReturnsFourStrategyPipeline()
{
PipelineCompactionStrategy pipeline =
Assert.IsType<PipelineCompactionStrategy>(
CompactionStrategy.Create(CompactionApproach.Aggressive, CompactionSize.Compact, CreateMockChatClient()));
Assert.Equal(4, pipeline.Strategies.Count);
Assert.IsType<ToolResultCompactionStrategy>(pipeline.Strategies[0]);
Assert.IsType<SummarizationCompactionStrategy>(pipeline.Strategies[1]);
Assert.IsType<SlidingWindowCompactionStrategy>(pipeline.Strategies[2]);
Assert.IsType<TruncationCompactionStrategy>(pipeline.Strategies[3]);
}
[Fact]
public void CreateAggressiveModerateReturnsFourStrategyPipeline()
{
PipelineCompactionStrategy pipeline =
Assert.IsType<PipelineCompactionStrategy>(
CompactionStrategy.Create(CompactionApproach.Aggressive, CompactionSize.Moderate, CreateMockChatClient()));
Assert.Equal(4, pipeline.Strategies.Count);
Assert.IsType<ToolResultCompactionStrategy>(pipeline.Strategies[0]);
Assert.IsType<SummarizationCompactionStrategy>(pipeline.Strategies[1]);
Assert.IsType<SlidingWindowCompactionStrategy>(pipeline.Strategies[2]);
Assert.IsType<TruncationCompactionStrategy>(pipeline.Strategies[3]);
}
[Fact]
public void CreateAggressiveGenerousReturnsFourStrategyPipeline()
{
PipelineCompactionStrategy pipeline =
Assert.IsType<PipelineCompactionStrategy>(
CompactionStrategy.Create(CompactionApproach.Aggressive, CompactionSize.Generous, CreateMockChatClient()));
Assert.Equal(4, pipeline.Strategies.Count);
Assert.IsType<ToolResultCompactionStrategy>(pipeline.Strategies[0]);
Assert.IsType<SummarizationCompactionStrategy>(pipeline.Strategies[1]);
Assert.IsType<SlidingWindowCompactionStrategy>(pipeline.Strategies[2]);
Assert.IsType<TruncationCompactionStrategy>(pipeline.Strategies[3]);
}
[Fact]
public void CreateAggressiveNullChatClientThrows()
{
Assert.Throws<ArgumentNullException>(
() => CompactionStrategy.Create(CompactionApproach.Aggressive, CompactionSize.Moderate, null));
}
// ── Invalid enum values ───────────────────────────────────────────────────
[Fact]
public void CreateInvalidApproachThrows()
{
Assert.Throws<ArgumentOutOfRangeException>(
() => CompactionStrategy.Create((CompactionApproach)99, CompactionSize.Moderate));
}
[Fact]
public void CreateInvalidSizeThrows()
{
Assert.Throws<ArgumentOutOfRangeException>(
() => CompactionStrategy.Create(CompactionApproach.Gentle, (CompactionSize)99));
}
// ── Size-threshold behavioral verification ────────────────────────────────
/// <summary>
/// Verifies that <see cref="CompactionSize.Compact"/> and <see cref="CompactionSize.Moderate"/>
/// configure different message thresholds: 18 messages (17 groups) should trigger the Compact
/// ToolResultCompaction stage (threshold 10) but NOT the Moderate one (threshold 20).
/// </summary>
[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<ChatMessage> 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).");
}
}