Compare commits

...
Author SHA1 Message Date
copilot-swe-agent[bot] d3a7a4da87 Initial plan 2026-03-05 16:33:47 +00:00
Chris Rickman 43d226f8ca Add sample to solution 2026-03-05 08:32:04 -08:00
ChrisandGitHub 1a8a58f753 Merge branch 'main' into crickman/feature-compaction-deux 2026-03-05 08:29:23 -08:00
Chris Rickman c513694b2f Remove working solution 2026-03-05 08:27:00 -08:00
Chris Rickman f42863e354 Test update 2026-03-05 08:25:19 -08:00
Chris Rickman 06f55c0494 Update tests 2026-03-05 08:10:16 -08:00
Chris Rickman 7e2c5ad4e6 Tuning 2026-03-05 03:10:40 -08:00
Chris Rickman 6ce0447ff6 Tests 2026-03-05 03:00:32 -08:00
Chris Rickman defb9dd51c Formatting 2026-03-05 02:51:05 -08:00
Chris Rickman f70423bd99 Cleanup 2026-03-05 02:45:27 -08:00
Chris Rickman 1428286bd1 Formatting 2026-03-05 02:41:26 -08:00
Chris Rickman 7608005dd7 Encoding 2026-03-05 02:36:22 -08:00
Chris Rickman dda15ea4b4 Updated 2026-03-05 02:35:52 -08:00
ChrisandGitHub b275d3410a Merge branch 'main' into crickman/feature-compaction-deux 2026-03-05 02:25:33 -08:00
Chris Rickman 0e8b9b283f Strategies 2026-03-05 02:25:00 -08:00
Chris Rickman eb8406214e Stable 2026-03-05 01:48:40 -08:00
Chris Rickman fcd60daed5 Checkpoint 2026-03-04 23:31:18 -08:00
Chris Rickman 23cf75be3c Checkpoint 2026-03-04 18:12:23 -08:00
30 changed files with 4828 additions and 9 deletions
+1
View File
@@ -108,6 +108,7 @@
<!-- Inference SDKs -->
<PackageVersion Include="AWSSDK.Extensions.Bedrock.MEAI" Version="4.0.5.1" />
<PackageVersion Include="Microsoft.ML.OnnxRuntimeGenAI" Version="0.10.0" />
<PackageVersion Include="Microsoft.ML.Tokenizers" Version="2.0.0" />
<PackageVersion Include="OllamaSharp" Version="5.4.8" />
<PackageVersion Include="OpenAI" Version="2.8.0" />
<!-- Identity -->
+1
View File
@@ -56,6 +56,7 @@
<Project Path="samples/02-agents/Agents/Agent_Step15_DeepResearch/Agent_Step15_DeepResearch.csproj" />
<Project Path="samples/02-agents/Agents/Agent_Step16_Declarative/Agent_Step16_Declarative.csproj" />
<Project Path="samples/02-agents/Agents/Agent_Step17_AdditionalAIContext/Agent_Step17_AdditionalAIContext.csproj" />
<Project Path="samples/02-agents/Agents/Agent_Step18_CompactionPipeline/Agent_Step18_CompactionPipeline.csproj" />
</Folder>
<Folder Name="/Samples/02-agents/DeclarativeAgents/">
<Project Path="samples/02-agents/DeclarativeAgents/ChatClient/DeclarativeChatClientAgents.csproj" />
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.OpenAI" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,104 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample demonstrates how to use a ChatHistoryCompactionPipeline as the ChatReducer for an agent's
// in-memory chat history. The pipeline chains multiple compaction strategies from gentle to aggressive:
// 1. ToolResultCompactionStrategy - Collapses old tool-call groups into concise summaries
// 2. SummarizationCompactionStrategy - LLM-compresses older conversation spans
// 3. SlidingWindowCompactionStrategy - Keeps only the most recent N user turns
// 4. TruncationCompactionStrategy - Emergency token-budget backstop
using System.ComponentModel;
using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Compaction;
using Microsoft.Extensions.AI;
var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
AzureOpenAIClient openAIClient = new(new Uri(endpoint), new DefaultAzureCredential());
// Create a chat client for the agent and a separate one for the summarization strategy.
// Using the same model for simplicity; in production, use a smaller/cheaper model for summarization.
IChatClient agentChatClient = openAIClient.GetChatClient(deploymentName).AsIChatClient();
IChatClient summarizerChatClient = openAIClient.GetChatClient(deploymentName).AsIChatClient();
// Define a tool the agent can use, so we can see tool-result compaction in action.
[Description("Look up the current price of a product by name.")]
static string LookupPrice([Description("The product name to look up.")] string productName) =>
productName.ToUpperInvariant() switch
{
"LAPTOP" => "The laptop costs $999.99.",
"KEYBOARD" => "The keyboard costs $79.99.",
"MOUSE" => "The mouse costs $29.99.",
_ => $"Sorry, I don't have pricing for '{productName}'."
};
// Configure the compaction pipeline with one of each strategy, ordered least to most aggressive.
PipelineCompactionStrategy compactionPipeline =
new(// 1. Gentle: collapse old tool-call groups into short summaries like "[Tool calls: LookupPrice]"
new ToolResultCompactionStrategy(CompactionTriggers.TokensExceed(0x200)),
// 2. Moderate: use an LLM to summarize older conversation spans into a concise message
new SummarizationCompactionStrategy(summarizerChatClient, CompactionTriggers.TokensExceed(0x500)),
// 3. Aggressive: keep only the last N user turns and their responses
new SlidingWindowCompactionStrategy(CompactionTriggers.TurnsExceed(4)),
// 4. Emergency: drop oldest groups until under the token budget
new TruncationCompactionStrategy(CompactionTriggers.TokensExceed(0x8000)));
// Create the agent with an in-memory chat history provider whose reducer is the compaction pipeline.
AIAgent agent =
agentChatClient.AsAIAgent(
new ChatClientAgentOptions
{
Name = "ShoppingAssistant",
ChatOptions = new()
{
Instructions =
"""
You are a helpful, but long winded, shopping assistant.
Help the user look up prices and compare products.
When responding, Be sure to be extra descriptive and use as
many words as possible without sounding ridiculous.
""",
Tools = [AIFunctionFactory.Create(LookupPrice)],
},
CompactionStrategy = compactionPipeline,
});
AgentSession session = await agent.CreateSessionAsync();
// Helper to print chat history size
void PrintChatHistory()
{
if (session.TryGetInMemoryChatHistory(out var history))
{
Console.WriteLine($" [Chat history: {history.Count} messages]\n");
}
}
// Run a multi-turn conversation with tool calls to exercise the pipeline.
string[] prompts =
[
"What's the price of a laptop?",
"How about a keyboard?",
"And a mouse?",
"Which product is the cheapest?",
"Can you compare the laptop and the keyboard for me?",
"What was the first product I asked about?",
"Thank you!",
];
foreach (string prompt in prompts)
{
Console.WriteLine($"User: {prompt}");
Console.WriteLine($"Agent: {await agent.RunAsync(prompt, session)}");
PrintChatHistory();
}
@@ -79,20 +79,21 @@ public sealed class InMemoryChatHistoryProvider : ChatHistoryProvider
/// <exception cref="ArgumentNullException"><paramref name="messages"/> is <see langword="null"/>.</exception>
public void SetMessages(AgentSession? session, List<ChatMessage> messages)
{
_ = Throw.IfNull(messages);
Throw.IfNull(messages);
var state = this._sessionState.GetOrInitializeState(session);
State state = this._sessionState.GetOrInitializeState(session);
state.Messages = messages;
}
/// <inheritdoc />
protected override async ValueTask<IEnumerable<ChatMessage>> ProvideChatHistoryAsync(InvokingContext context, CancellationToken cancellationToken = default)
{
var state = this._sessionState.GetOrInitializeState(context.Session);
State state = this._sessionState.GetOrInitializeState(context.Session);
if (this.ReducerTriggerEvent is InMemoryChatHistoryProviderOptions.ChatReducerTriggerEvent.BeforeMessagesRetrieval && this.ChatReducer is not null)
{
state.Messages = (await this.ChatReducer.ReduceAsync(state.Messages, cancellationToken).ConfigureAwait(false)).ToList();
// Apply pre-invocation compaction strategy if configured
await this.CompactMessagesAsync(state, cancellationToken).ConfigureAwait(false);
}
return state.Messages;
@@ -101,18 +102,31 @@ public sealed class InMemoryChatHistoryProvider : ChatHistoryProvider
/// <inheritdoc />
protected override async ValueTask StoreChatHistoryAsync(InvokedContext context, CancellationToken cancellationToken = default)
{
var state = this._sessionState.GetOrInitializeState(context.Session);
State state = this._sessionState.GetOrInitializeState(context.Session);
// Add request and response messages to the provider
var allNewMessages = context.RequestMessages.Concat(context.ResponseMessages ?? []);
state.Messages.AddRange(allNewMessages);
if (this.ReducerTriggerEvent is InMemoryChatHistoryProviderOptions.ChatReducerTriggerEvent.AfterMessageAdded && this.ChatReducer is not null)
if (this.ReducerTriggerEvent is InMemoryChatHistoryProviderOptions.ChatReducerTriggerEvent.AfterMessageAdded)
{
state.Messages = (await this.ChatReducer.ReduceAsync(state.Messages, cancellationToken).ConfigureAwait(false)).ToList();
// Apply pre-write compaction strategy if configured
await this.CompactMessagesAsync(state, cancellationToken).ConfigureAwait(false);
}
}
private async Task CompactMessagesAsync(State state, CancellationToken cancellationToken = default)
{
if (this.ChatReducer is not null)
{
// ChatReducer takes precedence, if configured
state.Messages = [.. await this.ChatReducer.ReduceAsync(state.Messages, cancellationToken).ConfigureAwait(false)];
return;
}
// %%% TODO: CONSIDER COMPACTION
}
/// <summary>
/// Represents the state of a <see cref="InMemoryChatHistoryProvider"/> stored in the <see cref="AgentSession.StateBag"/>.
/// </summary>
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using Microsoft.Agents.AI.Compaction;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI;
@@ -45,6 +46,22 @@ public sealed class ChatClientAgentOptions
/// </summary>
public IEnumerable<AIContextProvider>? AIContextProviders { get; set; }
/// <summary>
/// Gets or sets the <see cref="CompactionStrategy"/> to use for in-run context compaction.
/// </summary>
/// <remarks>
/// <para>
/// When set, this strategy is applied to the message list before each call to the underlying
/// <see cref="IChatClient"/> during agent execution. This keeps the context within token limits
/// as tool calls accumulate during long-running agent invocations.
/// </para>
/// <para>
/// The strategy organizes messages into atomic groups (preserving tool-call/result pairings)
/// before applying compaction logic. See <see cref="CompactionStrategy"/> for details.
/// </para>
/// </remarks>
public CompactionStrategy? CompactionStrategy { get; set; }
/// <summary>
/// Gets or sets a value indicating whether to use the provided <see cref="IChatClient"/> instance as is,
/// without applying any default decorators.
@@ -101,6 +118,7 @@ public sealed class ChatClientAgentOptions
ChatOptions = this.ChatOptions?.Clone(),
ChatHistoryProvider = this.ChatHistoryProvider,
AIContextProviders = this.AIContextProviders is null ? null : new List<AIContextProvider>(this.AIContextProviders),
CompactionStrategy = this.CompactionStrategy,
UseProvidedChatClientAsIs = this.UseProvidedChatClientAsIs,
ClearOnChatHistoryProviderConflict = this.ClearOnChatHistoryProviderConflict,
WarnOnChatHistoryProviderConflict = this.WarnOnChatHistoryProviderConflict,
@@ -4,6 +4,7 @@ using System;
using System.Collections.Generic;
using System.Diagnostics;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Compaction;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
@@ -53,9 +54,16 @@ public static class ChatClientExtensions
{
var chatBuilder = chatClient.AsBuilder();
// Add compaction as the innermost middleware so it runs before every LLM call,
// including those triggered by tool call iterations within FunctionInvokingChatClient.
if (options?.CompactionStrategy is { } compactionStrategy)
{
chatBuilder.Use(innerClient => new CompactingChatClient(innerClient, compactionStrategy));
}
if (chatClient.GetService<FunctionInvokingChatClient>() is null)
{
_ = chatBuilder.Use((innerClient, services) =>
chatBuilder.Use((innerClient, services) =>
{
var loggerFactory = services.GetService<ILoggerFactory>();
@@ -0,0 +1,138 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Runtime.CompilerServices;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Compaction;
/// <summary>
/// A delegating <see cref="IChatClient"/> that applies an <see cref="CompactionStrategy"/> to the message list
/// before each call to the inner chat client.
/// </summary>
/// <remarks>
/// <para>
/// This client is used for in-run compaction during the tool loop. It is inserted into the
/// <see cref="IChatClient"/> pipeline before the `FunctionInvokingChatClient` so that
/// compaction is applied before every LLM call, including those triggered by tool call iterations.
/// </para>
/// <para>
/// The compaction strategy organizes messages into atomic groups (preserving tool-call/result pairings)
/// before applying compaction logic. Only included messages are forwarded to the inner client.
/// </para>
/// </remarks>
internal sealed class CompactingChatClient : DelegatingChatClient
{
private readonly CompactionStrategy _compactionStrategy;
private readonly ProviderSessionState<State> _sessionState;
/// <summary>
/// Initializes a new instance of the <see cref="CompactingChatClient"/> class.
/// </summary>
/// <param name="innerClient">The inner chat client to delegate to.</param>
/// <param name="compactionStrategy">The compaction strategy to apply before each call.</param>
public CompactingChatClient(IChatClient innerClient, CompactionStrategy compactionStrategy)
: base(innerClient)
{
this._compactionStrategy = Throw.IfNull(compactionStrategy);
this._sessionState = new ProviderSessionState<State>(
_ => new State(),
Convert.ToBase64String(BitConverter.GetBytes(compactionStrategy.GetHashCode())),
AgentJsonUtilities.DefaultOptions);
}
/// <inheritdoc/>
public override async Task<ChatResponse> GetResponseAsync(
IEnumerable<ChatMessage> messages,
ChatOptions? options = null,
CancellationToken cancellationToken = default)
{
IEnumerable<ChatMessage> compactedMessages = await this.ApplyCompactionAsync(messages, cancellationToken).ConfigureAwait(false);
return await base.GetResponseAsync(compactedMessages, options, cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc/>
public override async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
IEnumerable<ChatMessage> messages,
ChatOptions? options = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
IEnumerable<ChatMessage> compactedMessages = await this.ApplyCompactionAsync(messages, cancellationToken).ConfigureAwait(false);
await foreach (ChatResponseUpdate update in base.GetStreamingResponseAsync(compactedMessages, options, cancellationToken).ConfigureAwait(false))
{
yield return update;
}
}
/// <inheritdoc />
public override object? GetService(Type serviceType, object? serviceKey = null)
{
Throw.IfNull(serviceType);
return
serviceKey is null && serviceType.IsInstanceOfType(typeof(CompactionStrategy)) ?
this._compactionStrategy :
base.GetService(serviceType, serviceKey);
}
private async Task<IEnumerable<ChatMessage>> ApplyCompactionAsync(
IEnumerable<ChatMessage> messages, CancellationToken cancellationToken)
{
List<ChatMessage> messageList = messages as List<ChatMessage> ?? [.. messages]; // %%% TODO - LIST COPY
AgentRunContext? currentAgentContext = AIAgent.CurrentRunContext;
if (currentAgentContext is null ||
currentAgentContext.Session is null)
{
// No session available — no reason to compact
return messages;
}
State state = this._sessionState.GetOrInitializeState(currentAgentContext.Session);
MessageIndex messageIndex;
if (state.MessageIndex.Count > 0)
{
// Update existing index
messageIndex = new(state.MessageIndex);
messageIndex.Update(messageList);
}
else
{
// First pass — initialize message index state
messageIndex = MessageIndex.Create(messageList);
}
// Apply compaction
Stopwatch stopwatch = Stopwatch.StartNew();
bool wasCompacted = await this._compactionStrategy.CompactAsync(messageIndex, cancellationToken).ConfigureAwait(false);
stopwatch.Stop();
Debug.WriteLine($"COMPACTION: {wasCompacted} - {stopwatch.ElapsedMilliseconds}ms");
if (wasCompacted)
{
state.MessageIndex = [.. messageIndex.Groups]; // %%% TODO - LIST COPY
}
return wasCompacted ? messageIndex.GetIncludedMessages() : messageList;
}
/// <summary>
/// Represents the state of a <see cref="InMemoryChatHistoryProvider"/> stored in the <see cref="AgentSession.StateBag"/>.
/// </summary>
public sealed class State
{
/// <summary>
/// Gets or sets the message index.
/// </summary>
[JsonPropertyName("messages")]
public List<MessageGroup> MessageIndex { get; set; } = [];
}
}
@@ -0,0 +1,127 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Compaction;
/// <summary>
/// Base class for strategies that compact a <see cref="MessageIndex"/> to reduce context size.
/// </summary>
/// <remarks>
/// <para>
/// Compaction strategies operate on <see cref="MessageIndex"/> instances, which organize messages
/// into atomic groups that respect the tool-call/result pairing constraint. Strategies mutate the collection
/// in place by marking groups as excluded, removing groups, or replacing message content (e.g., with summaries).
/// </para>
/// <para>
/// Every strategy requires a <see cref="CompactionTrigger"/> that determines whether compaction should
/// proceed based on current <see cref="MessageIndex"/> metrics (token count, message count, turn count, etc.).
/// The base class evaluates this trigger at the start of <see cref="CompactAsync"/> and skips compaction when
/// the trigger returns <see langword="false"/>.
/// </para>
/// <para>
/// An optional <b>target</b> condition controls when compaction stops. Strategies incrementally exclude
/// groups and re-evaluate the target after each exclusion, stopping as soon as the target returns
/// <see langword="true"/>. When no target is specified, it defaults to the inverse of the trigger —
/// meaning compaction stops when the trigger condition would no longer fire.
/// </para>
/// <para>
/// Strategies can be applied at three lifecycle points:
/// <list type="bullet">
/// <item><description><b>In-run</b>: During the tool loop, before each LLM call, to keep context within token limits.</description></item>
/// <item><description><b>Pre-write</b>: Before persisting messages to storage via <see cref="ChatHistoryProvider"/>.</description></item>
/// <item><description><b>On existing storage</b>: As a maintenance operation to compact stored history.</description></item>
/// </list>
/// </para>
/// <para>
/// Multiple strategies can be composed by applying them sequentially to the same <see cref="MessageIndex"/>
/// via <see cref="PipelineCompactionStrategy"/>.
/// </para>
/// </remarks>
public abstract class CompactionStrategy
{
/// <summary>
/// Initializes a new instance of the <see cref="CompactionStrategy"/> class.
/// </summary>
/// <param name="trigger">
/// The <see cref="CompactionTrigger"/> that determines whether compaction should proceed.
/// </param>
/// <param name="target">
/// An optional target condition that controls when compaction stops. Strategies re-evaluate
/// this predicate after each incremental exclusion and stop when it returns <see langword="true"/>.
/// When <see langword="null"/>, defaults to the inverse of the <paramref name="trigger"/> — compaction
/// stops as soon as the trigger condition would no longer fire.
/// </param>
protected CompactionStrategy(CompactionTrigger trigger, CompactionTrigger? target = null)
{
this.Trigger = Throw.IfNull(trigger);
this.Target = target ?? (index => !trigger(index));
}
/// <summary>
/// Gets the trigger predicate that controls when compaction proceeds.
/// </summary>
protected CompactionTrigger Trigger { get; }
/// <summary>
/// Gets the target predicate that controls when compaction stops.
/// Strategies re-evaluate this after each incremental exclusion and stop when it returns <see langword="true"/>.
/// </summary>
protected CompactionTrigger Target { get; }
/// <summary>
/// Evaluates the <see cref="Trigger"/> and, when it fires, delegates to
/// <see cref="ApplyCompactionAsync"/> and reports compaction metrics.
/// </summary>
/// <param name="index">The message index to compact. The strategy mutates this collection in place.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
/// <returns>A task representing the asynchronous operation. The task result is <see langword="true"/> if compaction occurred, <see langword="false"/> otherwise.</returns>
public async Task<bool> CompactAsync(MessageIndex index, CancellationToken cancellationToken = default)
{
if (!this.Trigger(index))
{
return false;
}
int beforeTokens = index.IncludedTokenCount;
int beforeGroups = index.IncludedGroupCount;
int beforeMessages = index.IncludedMessageCount;
Stopwatch stopwatch = Stopwatch.StartNew();
bool compacted = await this.ApplyCompactionAsync(index, cancellationToken).ConfigureAwait(false);
stopwatch.Stop();
if (compacted)
{
Debug.WriteLine(
$"""
COMPACTION: {this.GetType().Name}
Duration {stopwatch.ElapsedMilliseconds}ms
Messages {beforeMessages} => {index.IncludedMessageCount}
Groups {beforeGroups} => {index.IncludedGroupCount}
Tokens {beforeTokens} => {index.IncludedTokenCount}
""");
}
return compacted;
}
/// <summary>
/// Applies the strategy-specific compaction logic to the specified message index.
/// </summary>
/// <remarks>
/// This method is called by <see cref="CompactAsync"/> only when the <see cref="Trigger"/>
/// returns <see langword="true"/>. Implementations do not need to evaluate the trigger or
/// report metrics — the base class handles both. Implementations should use <see cref="Target"/>
/// to determine when to stop compacting incrementally.
/// </remarks>
/// <param name="index">The message index to compact. The strategy mutates this collection in place.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
/// <returns>A task whose result is <see langword="true"/> if any compaction was performed, <see langword="false"/> otherwise.</returns>
protected abstract Task<bool> ApplyCompactionAsync(MessageIndex index, CancellationToken cancellationToken);
}
@@ -0,0 +1,10 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI.Compaction;
/// <summary>
/// A predicate that evaluates whether compaction should proceed based on current <see cref="MessageIndex"/> metrics.
/// </summary>
/// <param name="index">The current message index with group, token, message, and turn metrics.</param>
/// <returns><see langword="true"/> if compaction should proceed; <see langword="false"/> to skip.</returns>
public delegate bool CompactionTrigger(MessageIndex index);
@@ -0,0 +1,114 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Linq;
namespace Microsoft.Agents.AI.Compaction;
/// <summary>
/// Provides factory methods for common <see cref="CompactionTrigger"/> predicates.
/// </summary>
/// <remarks>
/// These triggers evaluate included (non-excluded) metrics from the <see cref="MessageIndex"/>.
/// Combine triggers with <see cref="All"/> or <see cref="Any"/> for compound conditions,
/// or write a custom lambda for full flexibility.
/// </remarks>
public static class CompactionTriggers
{
/// <summary>
/// Always trigger compaction, regardless of the message index state.
/// </summary>
public static readonly CompactionTrigger Always =
_ => true;
/// <summary>
/// Always trigger compaction, regardless of the message index state.
/// </summary>
public static readonly CompactionTrigger Never =
_ => false;
/// <summary>
/// Creates a trigger that fires when the included token count is below the specified maximum.
/// </summary>
/// <param name="maxTokens">The token threshold. Compaction proceeds when included tokens exceed this value.</param>
/// <returns>A <see cref="CompactionTrigger"/> that evaluates included token count.</returns>
public static CompactionTrigger TokensBelow(int maxTokens) =>
index => index.IncludedTokenCount < maxTokens;
/// <summary>
/// Creates a trigger that fires when the included token count exceeds the specified maximum.
/// </summary>
/// <param name="maxTokens">The token threshold. Compaction proceeds when included tokens exceed this value.</param>
/// <returns>A <see cref="CompactionTrigger"/> that evaluates included token count.</returns>
public static CompactionTrigger TokensExceed(int maxTokens) =>
index => index.IncludedTokenCount > maxTokens;
/// <summary>
/// Creates a trigger that fires when the included message count exceeds the specified maximum.
/// </summary>
/// <param name="maxMessages">The message threshold. Compaction proceeds when included messages exceed this value.</param>
/// <returns>A <see cref="CompactionTrigger"/> that evaluates included message count.</returns>
public static CompactionTrigger MessagesExceed(int maxMessages) =>
index => index.IncludedMessageCount > maxMessages;
/// <summary>
/// Creates a trigger that fires when the included user turn count exceeds the specified maximum.
/// </summary>
/// <param name="maxTurns">The turn threshold. Compaction proceeds when included turns exceed this value.</param>
/// <returns>A <see cref="CompactionTrigger"/> that evaluates included turn count.</returns>
public static CompactionTrigger TurnsExceed(int maxTurns) =>
index => index.IncludedTurnCount > maxTurns;
/// <summary>
/// Creates a trigger that fires when the included group count exceeds the specified maximum.
/// </summary>
/// <param name="maxGroups">The group threshold. Compaction proceeds when included groups exceed this value.</param>
/// <returns>A <see cref="CompactionTrigger"/> that evaluates included group count.</returns>
public static CompactionTrigger GroupsExceed(int maxGroups) =>
index => index.IncludedGroupCount > maxGroups;
/// <summary>
/// Creates a trigger that fires when the included message index contains at least one
/// non-excluded <see cref="MessageGroupKind.ToolCall"/> group.
/// </summary>
/// <returns>A <see cref="CompactionTrigger"/> that evaluates included tool call presence.</returns>
public static CompactionTrigger HasToolCalls() =>
index => index.Groups.Any(g => !g.IsExcluded && g.Kind == MessageGroupKind.ToolCall);
/// <summary>
/// Creates a compound trigger that fires only when <b>all</b> of the specified triggers fire.
/// </summary>
/// <param name="triggers">The triggers to combine with logical AND.</param>
/// <returns>A <see cref="CompactionTrigger"/> that requires all conditions to be met.</returns>
public static CompactionTrigger All(params CompactionTrigger[] triggers) =>
index =>
{
for (int i = 0; i < triggers.Length; i++)
{
if (!triggers[i](index))
{
return false;
}
}
return true;
};
/// <summary>
/// Creates a compound trigger that fires when <b>any</b> of the specified triggers fire.
/// </summary>
/// <param name="triggers">The triggers to combine with logical OR.</param>
/// <returns>A <see cref="CompactionTrigger"/> that requires at least one condition to be met.</returns>
public static CompactionTrigger Any(params CompactionTrigger[] triggers) =>
index =>
{
for (int i = 0; i < triggers.Length; i++)
{
if (triggers[i](index))
{
return true;
}
}
return false;
};
}
@@ -0,0 +1,110 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Text.Json.Serialization;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Compaction;
/// <summary>
/// Represents a logical group of <see cref="ChatMessage"/> instances that must be kept or removed together during compaction.
/// </summary>
/// <remarks>
/// <para>
/// Message groups ensure atomic preservation of related messages. For example, an assistant message
/// containing tool calls and its corresponding tool result messages form a <see cref="MessageGroupKind.ToolCall"/>
/// group — removing one without the other would cause LLM API errors.
/// </para>
/// <para>
/// Groups also support exclusion semantics: a group can be marked as excluded (with an optional reason)
/// to indicate it should not be included in the messages sent to the model, while still being preserved
/// for diagnostics, storage, or later re-inclusion.
/// </para>
/// <para>
/// Each group tracks its <see cref="MessageCount"/>, <see cref="ByteCount"/>, and <see cref="TokenCount"/>
/// so that <see cref="MessageIndex"/> can efficiently aggregate totals across all or only included groups.
/// These values are computed by <see cref="MessageIndex.Create"/> and passed into the constructor.
/// </para>
/// </remarks>
public sealed class MessageGroup
{
/// <summary>
/// The <see cref="ChatMessage.AdditionalProperties"/> key used to identify a message as a compaction summary.
/// </summary>
/// <remarks>
/// When this key is present with a value of <see langword="true"/>, the message is classified as
/// <see cref="MessageGroupKind.Summary"/> by <see cref="MessageIndex.Create"/>.
/// </remarks>
public static readonly string SummaryPropertyKey = "_is_summary";
/// <summary>
/// Initializes a new instance of the <see cref="MessageGroup"/> class.
/// </summary>
/// <param name="kind">The kind of message group.</param>
/// <param name="messages">The messages in this group. The list is captured as a read-only snapshot.</param>
/// <param name="byteCount">The total UTF-8 byte count of the text content in the messages.</param>
/// <param name="tokenCount">The token count for the messages, computed by a tokenizer or estimated.</param>
/// <param name="turnIndex">
/// The zero-based user turn this group belongs to, or <see langword="null"/> for groups that precede
/// the first user message (e.g., system messages).
/// </param>
[JsonConstructor]
public MessageGroup(MessageGroupKind kind, IReadOnlyList<ChatMessage> messages, int byteCount, int tokenCount, int? turnIndex = null)
{
this.Kind = kind;
this.Messages = messages;
this.MessageCount = messages.Count;
this.ByteCount = byteCount;
this.TokenCount = tokenCount;
this.TurnIndex = turnIndex;
}
/// <summary>
/// Gets the kind of this message group.
/// </summary>
public MessageGroupKind Kind { get; }
/// <summary>
/// Gets the messages in this group.
/// </summary>
public IReadOnlyList<ChatMessage> Messages { get; }
/// <summary>
/// Gets the number of messages in this group.
/// </summary>
public int MessageCount { get; }
/// <summary>
/// Gets the total UTF-8 byte count of the text content in this group's messages.
/// </summary>
public int ByteCount { get; }
/// <summary>
/// Gets the estimated or actual token count for this group's messages.
/// </summary>
public int TokenCount { get; }
/// <summary>
/// Gets the zero-based user turn index this group belongs to, or <see langword="null"/>
/// for groups that precede the first user message (e.g., system messages).
/// </summary>
/// <remarks>
/// A turn starts with a <see cref="MessageGroupKind.User"/> group and includes all subsequent
/// non-user, non-system groups until the next user group or end of conversation.
/// </remarks>
public int? TurnIndex { get; }
/// <summary>
/// Gets or sets a value indicating whether this group is excluded from the projected message list.
/// </summary>
/// <remarks>
/// Excluded groups are preserved in the collection for diagnostics or storage purposes
/// but are not included when calling <see cref="MessageIndex.GetIncludedMessages"/>.
/// </remarks>
public bool IsExcluded { get; set; }
/// <summary>
/// Gets or sets an optional reason explaining why this group was excluded.
/// </summary>
public string? ExcludeReason { get; set; }
}
@@ -0,0 +1,51 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI.Compaction;
/// <summary>
/// Identifies the kind of a <see cref="MessageGroup"/>.
/// </summary>
/// <remarks>
/// Message groups are used to classify logically related messages that must be kept together
/// during compaction operations. For example, an assistant message containing tool calls
/// and its corresponding tool result messages form an atomic <see cref="ToolCall"/> group.
/// </remarks>
public enum MessageGroupKind
{
/// <summary>
/// A system message group containing one or more system messages.
/// </summary>
System,
/// <summary>
/// A user message group containing a single user message.
/// </summary>
User,
/// <summary>
/// An assistant message group containing a single assistant text response (no tool calls).
/// </summary>
AssistantText,
/// <summary>
/// An atomic tool call group containing an assistant message with tool calls
/// followed by the corresponding tool result messages.
/// </summary>
/// <remarks>
/// This group must be treated as an atomic unit during compaction. Removing the assistant
/// message without its tool results (or vice versa) will cause LLM API errors.
/// </remarks>
ToolCall,
#pragma warning disable IDE0001 // Simplify Names
/// <summary>
/// A summary message group produced by a compaction strategy (e.g., <c>SummarizationCompactionStrategy</c>).
/// </summary>
/// <remarks>
/// Summary groups replace previously compacted messages with a condensed representation.
/// They are identified by the <see cref="MessageGroup.SummaryPropertyKey"/> metadata entry
/// on the underlying <see cref="Microsoft.Extensions.AI.ChatMessage"/>.
/// </remarks>
#pragma warning restore IDE0001 // Simplify Names
Summary,
}
@@ -0,0 +1,373 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Text;
using Microsoft.Extensions.AI;
using Microsoft.ML.Tokenizers;
namespace Microsoft.Agents.AI.Compaction;
/// <summary>
/// Represents a collection of <see cref="MessageGroup"/> instances derived from a flat list of <see cref="ChatMessage"/> objects.
/// </summary>
/// <remarks>
/// <para>
/// <see cref="MessageIndex"/> provides structural grouping of messages into logical units that
/// respect the atomic group preservation constraint: tool call assistant messages and their corresponding
/// tool result messages are always grouped together.
/// </para>
/// <para>
/// This collection supports exclusion-based projection, where groups can be marked as excluded
/// without being removed, allowing compaction strategies to toggle visibility while preserving
/// the full history for diagnostics or storage.
/// </para>
/// <para>
/// Each group tracks its own <see cref="MessageGroup.MessageCount"/>, <see cref="MessageGroup.ByteCount"/>,
/// and <see cref="MessageGroup.TokenCount"/>. The collection provides aggregate properties for both
/// the total (all groups) and included (non-excluded groups only) counts.
/// </para>
/// <para>
/// Instances created via <see cref="Create"/> track internal state that enables efficient incremental
/// updates via <see cref="Update"/>. This allows caching a <see cref="MessageIndex"/> instance and
/// appending only new messages without reprocessing the entire history.
/// </para>
/// </remarks>
public sealed class MessageIndex
{
private int _currentTurn;
/// <summary>
/// Gets the list of message groups in this collection.
/// </summary>
public IList<MessageGroup> Groups { get; }
/// <summary>
/// Gets the tokenizer used for computing token counts, or <see langword="null"/> if token counts are estimated.
/// </summary>
public Tokenizer? Tokenizer { get; }
/// <summary>
/// Gets the number of raw messages that have been processed into groups.
/// </summary>
/// <remarks>
/// This value is set by <see cref="Create"/> and updated by <see cref="Update"/>.
/// It is used by <see cref="Update"/> to determine which messages are new and need processing.
/// </remarks>
public int ProcessedMessageCount { get; private set; }
/// <summary>
/// Initializes a new instance of the <see cref="MessageIndex"/> class with the specified groups.
/// </summary>
/// <param name="groups">The message groups.</param>
/// <param name="tokenizer">An optional tokenizer retained for computing token counts when adding new groups.</param>
public MessageIndex(IList<MessageGroup> groups, Tokenizer? tokenizer = null)
{
this.Groups = groups;
this.Tokenizer = tokenizer;
// Restore turn counter from the last group that has a TurnIndex
for (int index = groups.Count - 1; index >= 0; --index)
{
if (this.Groups[index].TurnIndex.HasValue)
{
this._currentTurn = this.Groups[index].TurnIndex!.Value;
break;
}
}
}
/// <summary>
/// Creates a <see cref="MessageIndex"/> from a flat list of <see cref="ChatMessage"/> instances.
/// </summary>
/// <param name="messages">The messages to group.</param>
/// <param name="tokenizer">
/// An optional <see cref="Tokenizer"/> for computing token counts on each group.
/// When <see langword="null"/>, token counts are estimated as <c>ByteCount / 4</c>.
/// </param>
/// <returns>A new <see cref="MessageIndex"/> with messages organized into logical groups.</returns>
/// <remarks>
/// The grouping algorithm:
/// <list type="bullet">
/// <item><description>System messages become <see cref="MessageGroupKind.System"/> groups.</description></item>
/// <item><description>User messages become <see cref="MessageGroupKind.User"/> groups.</description></item>
/// <item><description>Assistant messages with tool calls, followed by their corresponding tool result messages, become <see cref="MessageGroupKind.ToolCall"/> groups.</description></item>
/// <item><description>Assistant messages marked with <see cref="MessageGroup.SummaryPropertyKey"/> become <see cref="MessageGroupKind.Summary"/> groups.</description></item>
/// <item><description>Assistant messages without tool calls become <see cref="MessageGroupKind.AssistantText"/> groups.</description></item>
/// </list>
/// </remarks>
public static MessageIndex Create(IList<ChatMessage> messages, Tokenizer? tokenizer = null)
{
Debug.WriteLine("COMPACTION: Creating index x{messages.Count} messages");
MessageIndex instance = new([], tokenizer);
instance.AppendFromMessages(messages, 0);
return instance;
}
/// <summary>
/// Incrementally updates the groups with new messages from the conversation.
/// </summary>
/// <param name="allMessages">
/// The full list of messages for the conversation. This must be the same list (or a replacement with the same
/// prefix) that was used to create or last update this instance.
/// </param>
/// <remarks>
/// <para>
/// If the message count exceeds <see cref="ProcessedMessageCount"/>, only the new (delta) messages
/// are processed and appended as new groups. Existing groups and their compaction state (exclusions)
/// are preserved, allowing compaction strategies to build on previous results.
/// </para>
/// <para>
/// If the message count is less than <see cref="ProcessedMessageCount"/> (e.g., after storage compaction
/// replaced messages with summaries), all groups are cleared and rebuilt from scratch.
/// </para>
/// <para>
/// If the message count equals <see cref="ProcessedMessageCount"/>, no work is performed.
/// </para>
/// </remarks>
public void Update(IList<ChatMessage> allMessages)
{
if (allMessages.Count == this.ProcessedMessageCount)
{
return; // No new messages
}
if (allMessages.Count < this.ProcessedMessageCount)
{
// Message list shrank (e.g., after storage compaction). Rebuild from scratch.
this.ProcessedMessageCount = 0;
}
if (this.ProcessedMessageCount == 0)
{
// First update on a manually constructed instance — clear any pre-existing groups
this.Groups.Clear();
this._currentTurn = 0;
}
// Process only the delta messages
this.AppendFromMessages(allMessages, this.ProcessedMessageCount);
}
private void AppendFromMessages(IList<ChatMessage> messages, int startIndex)
{
int index = startIndex;
while (index < messages.Count)
{
ChatMessage message = messages[index];
if (message.Role == ChatRole.System)
{
// System messages are not part of any turn
this.Groups.Add(CreateGroup(MessageGroupKind.System, [message], this.Tokenizer, turnIndex: null));
index++;
}
else if (message.Role == ChatRole.User)
{
this._currentTurn++;
this.Groups.Add(CreateGroup(MessageGroupKind.User, [message], this.Tokenizer, this._currentTurn));
index++;
}
else if (message.Role == ChatRole.Assistant && HasToolCalls(message))
{
List<ChatMessage> groupMessages = [message];
index++;
// Collect all subsequent tool result messages
while (index < messages.Count && messages[index].Role == ChatRole.Tool)
{
groupMessages.Add(messages[index]);
index++;
}
this.Groups.Add(CreateGroup(MessageGroupKind.ToolCall, groupMessages, this.Tokenizer, this._currentTurn));
}
else if (message.Role == ChatRole.Assistant && IsSummaryMessage(message))
{
this.Groups.Add(CreateGroup(MessageGroupKind.Summary, [message], this.Tokenizer, this._currentTurn));
index++;
}
else
{
this.Groups.Add(CreateGroup(MessageGroupKind.AssistantText, [message], this.Tokenizer, this._currentTurn));
index++;
}
}
this.ProcessedMessageCount = messages.Count;
}
/// <summary>
/// Creates a new <see cref="MessageGroup"/> with byte and token counts computed using this collection's
/// <see cref="Tokenizer"/>, and adds it to the <see cref="Groups"/> list at the specified index.
/// </summary>
/// <param name="index">The zero-based index at which the group should be inserted.</param>
/// <param name="kind">The kind of message group.</param>
/// <param name="messages">The messages in the group.</param>
/// <param name="turnIndex">The optional turn index to assign to the new group.</param>
/// <returns>The newly created <see cref="MessageGroup"/>.</returns>
public MessageGroup InsertGroup(int index, MessageGroupKind kind, IReadOnlyList<ChatMessage> messages, int? turnIndex = null)
{
MessageGroup group = CreateGroup(kind, messages, this.Tokenizer, turnIndex);
this.Groups.Insert(index, group);
return group;
}
/// <summary>
/// Creates a new <see cref="MessageGroup"/> with byte and token counts computed using this collection's
/// <see cref="Tokenizer"/>, and appends it to the end of the <see cref="Groups"/> list.
/// </summary>
/// <param name="kind">The kind of message group.</param>
/// <param name="messages">The messages in the group.</param>
/// <param name="turnIndex">The optional turn index to assign to the new group.</param>
/// <returns>The newly created <see cref="MessageGroup"/>.</returns>
public MessageGroup AddGroup(MessageGroupKind kind, IReadOnlyList<ChatMessage> messages, int? turnIndex = null)
{
MessageGroup group = CreateGroup(kind, messages, this.Tokenizer, turnIndex);
this.Groups.Add(group);
return group;
}
/// <summary>
/// Returns only the messages from groups that are not excluded.
/// </summary>
/// <returns>A list of <see cref="ChatMessage"/> instances from included groups, in order.</returns>
public IEnumerable<ChatMessage> GetIncludedMessages() =>
this.Groups.Where(group => !group.IsExcluded).SelectMany(group => group.Messages);
/// <summary>
/// Returns all messages from all groups, including excluded ones.
/// </summary>
/// <returns>A list of all <see cref="ChatMessage"/> instances, in order.</returns>
public IEnumerable<ChatMessage> GetAllMessages() => this.Groups.SelectMany(group => group.Messages);
/// <summary>
/// Gets the total number of groups, including excluded ones.
/// </summary>
public int TotalGroupCount => this.Groups.Count;
/// <summary>
/// Gets the total number of messages across all groups, including excluded ones.
/// </summary>
public int TotalMessageCount => this.Groups.Sum(g => g.MessageCount);
/// <summary>
/// Gets the total UTF-8 byte count across all groups, including excluded ones.
/// </summary>
public int TotalByteCount => this.Groups.Sum(g => g.ByteCount);
/// <summary>
/// Gets the total token count across all groups, including excluded ones.
/// </summary>
public int TotalTokenCount => this.Groups.Sum(g => g.TokenCount);
/// <summary>
/// Gets the total number of groups that are not excluded.
/// </summary>
public int IncludedGroupCount => this.Groups.Count(g => !g.IsExcluded);
/// <summary>
/// Gets the total number of messages across all included (non-excluded) groups.
/// </summary>
public int IncludedMessageCount => this.Groups.Where(g => !g.IsExcluded).Sum(g => g.MessageCount);
/// <summary>
/// Gets the total UTF-8 byte count across all included (non-excluded) groups.
/// </summary>
public int IncludedByteCount => this.Groups.Where(g => !g.IsExcluded).Sum(g => g.ByteCount);
/// <summary>
/// Gets the total token count across all included (non-excluded) groups.
/// </summary>
public int IncludedTokenCount => this.Groups.Where(g => !g.IsExcluded).Sum(g => g.TokenCount);
/// <summary>
/// Gets the total number of user turns across all groups (including those with excluded groups).
/// </summary>
public int TotalTurnCount => this.Groups.Select(group => group.TurnIndex).Distinct().Count(turnIndex => turnIndex is not null);
/// <summary>
/// Gets the number of user turns that have at least one non-excluded group.
/// </summary>
public int IncludedTurnCount => this.Groups.Where(group => !group.IsExcluded).Select(group => group.TurnIndex).Distinct().Count(turnIndex => turnIndex is not null);
/// <summary>
/// Returns all groups that belong to the specified user turn.
/// </summary>
/// <param name="turnIndex">The zero-based turn index.</param>
/// <returns>The groups belonging to the turn, in order.</returns>
public IEnumerable<MessageGroup> GetTurnGroups(int turnIndex) =>
this.Groups.Where(g => g.TurnIndex == turnIndex);
/// <summary>
/// Computes the UTF-8 byte count for a set of messages.
/// </summary>
/// <param name="messages">The messages to compute byte count for.</param>
/// <returns>The total UTF-8 byte count of all message text content.</returns>
public static int ComputeByteCount(IReadOnlyList<ChatMessage> messages)
{
int total = 0;
for (int i = 0; i < messages.Count; i++)
{
string text = messages[i].Text ?? string.Empty;
if (text.Length > 0)
{
total += Encoding.UTF8.GetByteCount(text);
}
}
return total;
}
/// <summary>
/// Computes the token count for a set of messages using the specified tokenizer.
/// </summary>
/// <param name="messages">The messages to compute token count for.</param>
/// <param name="tokenizer">The tokenizer to use for counting tokens.</param>
/// <returns>The total token count across all message text content.</returns>
public static int ComputeTokenCount(IReadOnlyList<ChatMessage> messages, Tokenizer tokenizer)
{
int total = 0;
for (int i = 0; i < messages.Count; i++)
{
string text = messages[i].Text ?? string.Empty;
if (text.Length > 0)
{
total += tokenizer.CountTokens(text);
}
}
return total;
}
private static MessageGroup CreateGroup(MessageGroupKind kind, IReadOnlyList<ChatMessage> messages, Tokenizer? tokenizer, int? turnIndex)
{
int byteCount = ComputeByteCount(messages);
int tokenCount = tokenizer is not null
? ComputeTokenCount(messages, tokenizer)
: byteCount / 4;
return new MessageGroup(kind, messages, byteCount, tokenCount, turnIndex);
}
private static bool HasToolCalls(ChatMessage message)
{
foreach (AIContent content in message.Contents)
{
if (content is FunctionCallContent)
{
return true;
}
}
return false;
}
private static bool IsSummaryMessage(ChatMessage message)
{
return message.AdditionalProperties?.TryGetValue(MessageGroup.SummaryPropertyKey, out object? value) is true
&& value is true;
}
}
@@ -0,0 +1,59 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Compaction;
/// <summary>
/// A compaction strategy that executes a sequential pipeline of <see cref="CompactionStrategy"/> instances
/// against the same <see cref="MessageIndex"/>.
/// </summary>
/// <remarks>
/// <para>
/// Each strategy in the pipeline operates on the result of the previous one, enabling composed behaviors
/// such as summarizing older messages first and then truncating to fit a token budget.
/// </para>
/// <para>
/// The pipeline's own <see cref="CompactionStrategy.Trigger"/> is evaluated first. If it returns
/// <see langword="false"/>, none of the child strategies are executed. Each child strategy also
/// evaluates its own trigger independently.
/// </para>
/// </remarks>
public sealed class PipelineCompactionStrategy : CompactionStrategy
{
/// <summary>
/// Initializes a new instance of the <see cref="PipelineCompactionStrategy"/> class.
/// </summary>
/// <param name="strategies">The ordered sequence of strategies to execute.</param>
public PipelineCompactionStrategy(params IEnumerable<CompactionStrategy> strategies)
: base(CompactionTriggers.Always)
{
this.Strategies = [.. Throw.IfNull(strategies)];
}
/// <summary>
/// Gets the ordered list of strategies in this pipeline.
/// </summary>
public IReadOnlyList<CompactionStrategy> Strategies { get; }
/// <inheritdoc/>
protected override async Task<bool> ApplyCompactionAsync(MessageIndex index, CancellationToken cancellationToken)
{
bool anyCompacted = false;
foreach (CompactionStrategy strategy in this.Strategies)
{
bool compacted = await strategy.CompactAsync(index, cancellationToken).ConfigureAwait(false);
if (compacted)
{
anyCompacted = true;
}
}
return anyCompacted;
}
}
@@ -0,0 +1,130 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace Microsoft.Agents.AI.Compaction;
/// <summary>
/// A compaction strategy that removes the oldest user turns and their associated response groups
/// to bound conversation length.
/// </summary>
/// <remarks>
/// <para>
/// This strategy always preserves system messages. It identifies user turns in the
/// conversation (via <see cref="MessageGroup.TurnIndex"/>) and excludes the oldest turns
/// one at a time until the <see cref="CompactionStrategy.Target"/> condition is met.
/// </para>
/// <para>
/// <see cref="MinimumPreserved"/> is a hard floor: even if the <see cref="CompactionStrategy.Target"/>
/// has not been reached, compaction will not touch the last <see cref="MinimumPreserved"/> non-system groups.
/// </para>
/// <para>
/// This strategy is more predictable than token-based truncation for bounding conversation
/// length, since it operates on logical turn boundaries rather than estimated token counts.
/// </para>
/// </remarks>
public sealed class SlidingWindowCompactionStrategy : CompactionStrategy
{
/// <summary>
/// The default minimum number of most-recent non-system groups to preserve.
/// </summary>
public const int DefaultMinimumPreserved = 1;
/// <summary>
/// Initializes a new instance of the <see cref="SlidingWindowCompactionStrategy"/> class.
/// </summary>
/// <param name="trigger">
/// The <see cref="CompactionTrigger"/> that controls when compaction proceeds.
/// Use <see cref="CompactionTriggers.TurnsExceed"/> for turn-based thresholds.
/// </param>
/// <param name="minimumPreserved">
/// The minimum number of most-recent non-system message groups to preserve.
/// This is a hard floor — compaction will not exclude groups beyond this limit,
/// regardless of the target condition.
/// </param>
/// <param name="target">
/// An optional target condition that controls when compaction stops. When <see langword="null"/>,
/// defaults to the inverse of the <paramref name="trigger"/> — compaction stops as soon as the trigger would no longer fire.
/// </param>
public SlidingWindowCompactionStrategy(CompactionTrigger trigger, int minimumPreserved = DefaultMinimumPreserved, CompactionTrigger? target = null)
: base(trigger, target)
{
this.MinimumPreserved = minimumPreserved;
}
/// <summary>
/// Gets the minimum number of most-recent non-system groups that are always preserved.
/// This is a hard floor that compaction cannot exceed, regardless of the target condition.
/// </summary>
public int MinimumPreserved { get; }
/// <inheritdoc/>
protected override Task<bool> ApplyCompactionAsync(MessageIndex index, CancellationToken cancellationToken)
{
// Identify protected groups: the N most-recent non-system, non-excluded groups
List<int> nonSystemIncludedIndices = [];
foreach (MessageGroup group in index.Groups)
{
if (!group.IsExcluded && group.Kind != MessageGroupKind.System)
{
nonSystemIncludedIndices.Add(index.Groups.IndexOf(group));
}
}
int protectedStart = Math.Max(0, nonSystemIncludedIndices.Count - this.MinimumPreserved);
HashSet<int> protectedGroupIndices = [];
for (int i = protectedStart; i < nonSystemIncludedIndices.Count; i++)
{
protectedGroupIndices.Add(nonSystemIncludedIndices[i]);
}
// Collect distinct included turn indices in order (oldest first), excluding protected groups
List<int> excludableTurns = [];
for (int i = 0; i < index.Groups.Count; i++)
{
MessageGroup group = index.Groups[i];
if (!group.IsExcluded
&& group.Kind != MessageGroupKind.System
&& !protectedGroupIndices.Contains(i)
&& group.TurnIndex is int turnIndex
&& !excludableTurns.Contains(turnIndex))
{
excludableTurns.Add(turnIndex);
}
}
// Exclude one turn at a time from oldest, re-checking target after each
bool compacted = false;
for (int t = 0; t < excludableTurns.Count; t++)
{
int turnToExclude = excludableTurns[t];
for (int i = 0; i < index.Groups.Count; i++)
{
MessageGroup group = index.Groups[i];
if (!group.IsExcluded
&& group.Kind != MessageGroupKind.System
&& !protectedGroupIndices.Contains(i)
&& group.TurnIndex == turnToExclude)
{
group.IsExcluded = true;
group.ExcludeReason = $"Excluded by {nameof(SlidingWindowCompactionStrategy)}";
}
}
compacted = true;
// Stop when target condition is met
if (this.Target(index))
{
break;
}
}
return Task.FromResult(compacted);
}
}
@@ -0,0 +1,182 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Compaction;
/// <summary>
/// A compaction strategy that uses an LLM to summarize older portions of the conversation,
/// replacing them with a single summary message that preserves key facts and context.
/// </summary>
/// <remarks>
/// <para>
/// This strategy protects system messages and the most recent <see cref="MinimumPreserved"/>
/// non-system groups. All older groups are collected and sent to the <see cref="IChatClient"/>
/// for summarization. The resulting summary replaces those messages as a single assistant message
/// with <see cref="MessageGroupKind.Summary"/>.
/// </para>
/// <para>
/// <see cref="MinimumPreserved"/> is a hard floor: even if the <see cref="CompactionStrategy.Target"/>
/// has not been reached, compaction will not touch the last <see cref="MinimumPreserved"/> non-system groups.
/// </para>
/// <para>
/// The <see cref="CompactionTrigger"/> predicate controls when compaction proceeds.
/// When <see langword="null"/>, the strategy compacts whenever there are groups older than the preserve window.
/// Use <see cref="CompactionTriggers"/> for common trigger conditions such as token thresholds.
/// </para>
/// </remarks>
public sealed class SummarizationCompactionStrategy : CompactionStrategy
{
/// <summary>
/// The default summarization prompt used when none is provided.
/// </summary>
public const string DefaultSummarizationPrompt =
"""
You are a conversation summarizer. Produce a concise summary of the conversation that preserves:
- Key facts, decisions, and user preferences
- Important context needed for future turns
- Tool call outcomes and their significance
Omit pleasantries and redundant exchanges. Be factual and brief.
""";
/// <summary>
/// Initializes a new instance of the <see cref="SummarizationCompactionStrategy"/> class.
/// </summary>
/// <param name="chatClient">The <see cref="IChatClient"/> to use for generating summaries. A smaller, faster model is recommended.</param>
/// <param name="trigger">
/// The <see cref="CompactionTrigger"/> that controls when compaction proceeds.
/// </param>
/// <param name="minimumPreserved">
/// The minimum number of most-recent non-system message groups to preserve.
/// This is a hard floor — compaction will not summarize groups beyond this limit,
/// regardless of the target condition. Defaults to 4, preserving the current and recent exchanges.
/// </param>
/// <param name="summarizationPrompt">
/// An optional custom system prompt for the summarization LLM call. When <see langword="null"/>,
/// <see cref="DefaultSummarizationPrompt"/> is used.
/// </param>
/// <param name="target">
/// An optional target condition that controls when compaction stops. When <see langword="null"/>,
/// defaults to the inverse of the <paramref name="trigger"/> — compaction stops as soon as the trigger would no longer fire.
/// </param>
public SummarizationCompactionStrategy(
IChatClient chatClient,
CompactionTrigger trigger,
int minimumPreserved = 4,
string? summarizationPrompt = null,
CompactionTrigger? target = null)
: base(trigger, target)
{
this.ChatClient = Throw.IfNull(chatClient);
this.MinimumPreserved = minimumPreserved;
this.SummarizationPrompt = summarizationPrompt ?? DefaultSummarizationPrompt;
}
/// <summary>
/// Gets the chat client used for generating summaries.
/// </summary>
public IChatClient ChatClient { get; }
/// <summary>
/// Gets the minimum number of most-recent non-system groups that are always preserved.
/// This is a hard floor that compaction cannot exceed, regardless of the target condition.
/// </summary>
public int MinimumPreserved { get; }
/// <summary>
/// Gets the prompt used when requesting summaries from the chat client.
/// </summary>
public string SummarizationPrompt { get; }
/// <inheritdoc/>
protected override async Task<bool> ApplyCompactionAsync(MessageIndex index, CancellationToken cancellationToken)
{
// Count non-system, non-excluded groups to determine which are protected
int nonSystemIncludedCount = 0;
for (int i = 0; i < index.Groups.Count; i++)
{
MessageGroup group = index.Groups[i];
if (!group.IsExcluded && group.Kind != MessageGroupKind.System)
{
nonSystemIncludedCount++;
}
}
int protectedFromEnd = Math.Min(this.MinimumPreserved, nonSystemIncludedCount);
int maxSummarizable = nonSystemIncludedCount - protectedFromEnd;
if (maxSummarizable <= 0)
{
return false;
}
// 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 < maxSummarizable; i++)
{
MessageGroup group = index.Groups[i];
if (group.IsExcluded || group.Kind == MessageGroupKind.System)
{
continue;
}
if (insertIndex < 0)
{
insertIndex = i;
}
// Build text representation of the group for summarization
foreach (ChatMessage message in group.Messages)
{
string text = message.Text ?? string.Empty;
if (!string.IsNullOrEmpty(text))
{
conversationText.AppendLine($"{message.Role}: {text}");
}
}
group.IsExcluded = true;
group.ExcludeReason = $"Summarized by {nameof(SummarizationCompactionStrategy)}";
summarized++;
// Stop marking when target condition is met
if (this.Target(index))
{
break;
}
}
// 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),
.. index.Groups
.Where(g => !g.IsExcluded && g.Kind == MessageGroupKind.System)
.SelectMany(g => g.Messages),
new ChatMessage(ChatRole.User, conversationText.ToString()),
new ChatMessage(ChatRole.User, "Summarize the conversation above concisely."),
],
cancellationToken: cancellationToken).ConfigureAwait(false);
string summaryText = string.IsNullOrWhiteSpace(response.Text) ? "[Summary unavailable]" : response.Text;
// Insert a summary group at the position of the first summarized group
ChatMessage summaryMessage = new(ChatRole.Assistant, $"[Summary]\n{summaryText}");
(summaryMessage.AdditionalProperties ??= [])[MessageGroup.SummaryPropertyKey] = true;
index.InsertGroup(insertIndex, MessageGroupKind.Summary, [summaryMessage]);
return true;
}
}
@@ -0,0 +1,150 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Compaction;
/// <summary>
/// A compaction strategy that collapses old tool call groups into single concise assistant
/// messages, removing the detailed tool results while preserving a record of which tools were called.
/// </summary>
/// <remarks>
/// <para>
/// This is the gentlest compaction strategy — it does not remove any user messages or
/// plain assistant responses. It only targets <see cref="MessageGroupKind.ToolCall"/>
/// groups outside the protected recent window, replacing each multi-message group
/// (assistant call + tool results) with a single assistant message like
/// <c>[Tool calls: get_weather, search_docs]</c>.
/// </para>
/// <para>
/// <see cref="MinimumPreserved"/> is a hard floor: even if the <see cref="CompactionStrategy.Target"/>
/// has not been reached, compaction will not touch the last <see cref="MinimumPreserved"/> non-system groups.
/// </para>
/// <para>
/// The <see cref="CompactionTrigger"/> predicate controls when compaction proceeds.
/// When <see langword="null"/>, a default compound trigger of
/// <see cref="CompactionTriggers.TokensExceed"/> AND <see cref="CompactionTriggers.HasToolCalls"/>
/// is used.
/// </para>
/// </remarks>
public sealed class ToolResultCompactionStrategy : CompactionStrategy
{
/// <summary>
/// The default minimum number of most-recent non-system groups to preserve.
/// </summary>
public const int DefaultMinimumPreserved = 2;
/// <summary>
/// Initializes a new instance of the <see cref="ToolResultCompactionStrategy"/> class.
/// </summary>
/// <param name="trigger">
/// The <see cref="CompactionTrigger"/> that controls when compaction proceeds.
/// </param>
/// <param name="minimumPreserved">
/// The minimum number of most-recent non-system message groups to preserve.
/// This is a hard floor — compaction will not collapse groups beyond this limit,
/// regardless of the target condition.
/// Defaults to <see cref="DefaultMinimumPreserved"/>, ensuring the current turn's tool interactions remain visible.
/// </param>
/// <param name="target">
/// An optional target condition that controls when compaction stops. When <see langword="null"/>,
/// defaults to the inverse of the <paramref name="trigger"/> — compaction stops as soon as the trigger would no longer fire.
/// </param>
public ToolResultCompactionStrategy(CompactionTrigger trigger, int minimumPreserved = DefaultMinimumPreserved, CompactionTrigger? target = null)
: base(trigger, target)
{
this.MinimumPreserved = minimumPreserved;
}
/// <summary>
/// Gets the minimum number of most-recent non-system groups that are always preserved.
/// This is a hard floor that compaction cannot exceed, regardless of the target condition.
/// </summary>
public int MinimumPreserved { get; }
/// <inheritdoc/>
protected override Task<bool> ApplyCompactionAsync(MessageIndex index, CancellationToken cancellationToken)
{
// Identify protected groups: the N most-recent non-system, non-excluded groups
List<int> nonSystemIncludedIndices = [];
for (int i = 0; i < index.Groups.Count; i++)
{
MessageGroup group = index.Groups[i];
if (!group.IsExcluded && group.Kind != MessageGroupKind.System)
{
nonSystemIncludedIndices.Add(i);
}
}
int protectedStart = Math.Max(0, nonSystemIncludedIndices.Count - this.MinimumPreserved);
HashSet<int> protectedGroupIndices = [];
for (int i = protectedStart; i < nonSystemIncludedIndices.Count; i++)
{
protectedGroupIndices.Add(nonSystemIncludedIndices[i]);
}
// Collect eligible tool groups in order (oldest first)
List<int> 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))
{
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<string> toolNames = [];
foreach (ChatMessage message in group.Messages)
{
if (message.Contents is not null)
{
foreach (AIContent content in message.Contents)
{
if (content is FunctionCallContent fcc)
{
toolNames.Add(fcc.Name);
}
}
}
}
// Exclude the original group and insert a collapsed replacement
group.IsExcluded = true;
group.ExcludeReason = $"Collapsed by {nameof(ToolResultCompactionStrategy)}";
string summary = $"[Tool calls: {string.Join(", ", toolNames)}]";
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);
}
}
@@ -0,0 +1,106 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Threading;
using System.Threading.Tasks;
namespace Microsoft.Agents.AI.Compaction;
/// <summary>
/// A compaction strategy that removes the oldest non-system message groups,
/// keeping at least <see cref="MinimumPreserved"/> most-recent groups intact.
/// </summary>
/// <remarks>
/// <para>
/// This strategy preserves system messages and removes the oldest non-system message groups first.
/// It respects atomic group boundaries — an assistant message with tool calls and its
/// corresponding tool result messages are always removed together.
/// </para>
/// <para>
/// <see cref="MinimumPreserved"/> is a hard floor: even if the <see cref="CompactionStrategy.Target"/>
/// has not been reached, compaction will not touch the last <see cref="MinimumPreserved"/> non-system groups.
/// </para>
/// <para>
/// The <see cref="CompactionTrigger"/> controls when compaction proceeds.
/// Use <see cref="CompactionTriggers"/> for common trigger conditions such as token or group thresholds.
/// </para>
/// </remarks>
public sealed class TruncationCompactionStrategy : CompactionStrategy
{
/// <summary>
/// The default minimum number of most-recent non-system groups to preserve.
/// </summary>
public const int DefaultMinimumPreserved = 32;
/// <summary>
/// Initializes a new instance of the <see cref="TruncationCompactionStrategy"/> class.
/// </summary>
/// <param name="trigger">
/// The <see cref="CompactionTrigger"/> that controls when compaction proceeds.
/// </param>
/// <param name="minimumPreserved">
/// The minimum number of most-recent non-system message groups to preserve.
/// This is a hard floor — compaction will not remove groups beyond this limit,
/// regardless of the target condition.
/// </param>
/// <param name="target">
/// An optional target condition that controls when compaction stops. When <see langword="null"/>,
/// defaults to the inverse of the <paramref name="trigger"/> — compaction stops as soon as the trigger would no longer fire.
/// </param>
public TruncationCompactionStrategy(CompactionTrigger trigger, int minimumPreserved = DefaultMinimumPreserved, CompactionTrigger? target = null)
: base(trigger, target)
{
this.MinimumPreserved = minimumPreserved;
}
/// <summary>
/// Gets the minimum number of most-recent non-system message groups that are always preserved.
/// This is a hard floor that compaction cannot exceed, regardless of the target condition.
/// </summary>
public int MinimumPreserved { get; }
/// <inheritdoc/>
protected override Task<bool> ApplyCompactionAsync(MessageIndex index, CancellationToken cancellationToken)
{
// Count removable (non-system, non-excluded) groups
int removableCount = 0;
for (int i = 0; i < index.Groups.Count; i++)
{
MessageGroup group = index.Groups[i];
if (!group.IsExcluded && group.Kind != MessageGroupKind.System)
{
removableCount++;
}
}
int maxRemovable = removableCount - this.MinimumPreserved;
if (maxRemovable <= 0)
{
return Task.FromResult(false);
}
// 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++)
{
MessageGroup group = index.Groups[i];
if (group.IsExcluded || group.Kind == MessageGroupKind.System)
{
continue;
}
group.IsExcluded = true;
group.ExcludeReason = $"Truncated by {nameof(TruncationCompactionStrategy)}";
removed++;
compacted = true;
// Stop when target condition is met
if (this.Target(index))
{
break;
}
}
return Task.FromResult(compacted);
}
}
@@ -18,10 +18,14 @@
<ItemGroup>
<ProjectReference Include="..\Microsoft.Agents.AI.Abstractions\Microsoft.Agents.AI.Abstractions.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.AI" />
<PackageReference Include="Microsoft.Extensions.VectorData.Abstractions" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.ML.Tokenizers" />
<PackageReference Include="System.Diagnostics.DiagnosticSource" />
</ItemGroup>
@@ -36,7 +40,7 @@
<InternalsVisibleTo Include="Microsoft.Agents.AI.UnitTests" />
<InternalsVisibleTo Include="Microsoft.Agents.AI.Declarative.UnitTests" />
<InternalsVisibleTo Include="Microsoft.Agents.AI.Hosting.UnitTests"/>
<InternalsVisibleTo Include="Microsoft.Agents.AI.Hosting.UnitTests" />
</ItemGroup>
</Project>
@@ -0,0 +1,399 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Reflection;
using System.Runtime.CompilerServices;
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 the <see cref="CompactingChatClient"/> class.
/// </summary>
public sealed class CompactingChatClientTests : IDisposable
{
/// <summary>
/// Restores the static <see cref="AIAgent.CurrentRunContext"/> after each test.
/// </summary>
public void Dispose()
{
SetCurrentRunContext(null);
}
[Fact]
public void ConstructorThrowsOnNullStrategyAsync()
{
Mock<IChatClient> mockInner = new();
Assert.Throws<ArgumentNullException>(() => new CompactingChatClient(mockInner.Object, null!));
}
[Fact]
public async Task GetResponseAsyncNoContextPassesThroughAsync()
{
// Arrange — no CurrentRunContext set → passthrough
ChatResponse expectedResponse = new([new ChatMessage(ChatRole.Assistant, "Hi")]);
Mock<IChatClient> mockInner = new();
mockInner.Setup(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(expectedResponse);
TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(100000));
CompactingChatClient client = new(mockInner.Object, strategy);
List<ChatMessage> messages =
[
new ChatMessage(ChatRole.User, "Hello"),
];
// Act
ChatResponse response = await client.GetResponseAsync(messages);
// Assert
Assert.Same(expectedResponse, response);
mockInner.Verify(c => c.GetResponseAsync(
messages,
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task GetResponseAsyncWithContextAppliesCompactionAsync()
{
// Arrange — set CurrentRunContext so compaction runs
ChatResponse expectedResponse = new([new ChatMessage(ChatRole.Assistant, "Done")]);
List<ChatMessage>? capturedMessages = null;
Mock<IChatClient> mockInner = new();
mockInner.Setup(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Callback<IEnumerable<ChatMessage>, ChatOptions?, CancellationToken>((msgs, _, _) =>
capturedMessages = [.. msgs])
.ReturnsAsync(expectedResponse);
// Strategy that always triggers and keeps only 1 group
TruncationCompactionStrategy strategy = new(_ => true, minimumPreserved: 1);
CompactingChatClient client = new(mockInner.Object, strategy);
TestAgentSession session = new();
SetRunContext(session);
List<ChatMessage> messages =
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
];
// Act
ChatResponse response = await client.GetResponseAsync(messages);
// Assert — compaction should have removed oldest groups
Assert.Same(expectedResponse, response);
Assert.NotNull(capturedMessages);
Assert.True(capturedMessages!.Count < messages.Count);
}
[Fact]
public async Task GetResponseAsyncNoCompactionNeededReturnsOriginalMessagesAsync()
{
// Arrange — trigger never fires → no compaction
ChatResponse expectedResponse = new([new ChatMessage(ChatRole.Assistant, "Hi")]);
List<ChatMessage>? capturedMessages = null;
Mock<IChatClient> mockInner = new();
mockInner.Setup(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Callback<IEnumerable<ChatMessage>, ChatOptions?, CancellationToken>((msgs, _, _) =>
capturedMessages = [.. msgs])
.ReturnsAsync(expectedResponse);
TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(100000));
CompactingChatClient client = new(mockInner.Object, strategy);
TestAgentSession session = new();
SetRunContext(session);
List<ChatMessage> messages =
[
new ChatMessage(ChatRole.User, "Hello"),
];
// Act
await client.GetResponseAsync(messages);
// Assert — original messages passed through
Assert.NotNull(capturedMessages);
Assert.Single(capturedMessages!);
Assert.Equal("Hello", capturedMessages[0].Text);
}
[Fact]
public async Task GetResponseAsyncWithExistingIndexUpdatesAsync()
{
// Arrange — call twice to exercise the "existing index" path (state.MessageIndex.Count > 0)
Mock<IChatClient> mockInner = new();
mockInner.Setup(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new ChatResponse([new ChatMessage(ChatRole.Assistant, "OK")]));
// Strategy that always triggers, keeping 1 group
TruncationCompactionStrategy strategy = new(_ => true, minimumPreserved: 1);
CompactingChatClient client = new(mockInner.Object, strategy);
TestAgentSession session = new();
SetRunContext(session);
List<ChatMessage> messages1 =
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
];
// First call — initializes state
await client.GetResponseAsync(messages1);
List<ChatMessage> messages2 =
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
new ChatMessage(ChatRole.Assistant, "A2"),
new ChatMessage(ChatRole.User, "Q3"),
];
// Act — second call exercises the update path
ChatResponse response = await client.GetResponseAsync(messages2);
// Assert
Assert.NotNull(response);
}
[Fact]
public async Task GetResponseAsyncNullSessionReturnsOriginalAsync()
{
// Arrange — CurrentRunContext exists but Session is null
ChatResponse expectedResponse = new([new ChatMessage(ChatRole.Assistant, "Hi")]);
Mock<IChatClient> mockInner = new();
mockInner.Setup(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(expectedResponse);
TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(100000));
CompactingChatClient client = new(mockInner.Object, strategy);
// Set context with null session
SetRunContext(null);
List<ChatMessage> messages = [new ChatMessage(ChatRole.User, "Hello")];
// Act
ChatResponse response = await client.GetResponseAsync(messages);
// Assert
Assert.Same(expectedResponse, response);
}
[Fact]
public async Task GetStreamingResponseAsyncNoContextPassesThroughAsync()
{
// Arrange — no CurrentRunContext
Mock<IChatClient> mockInner = new();
ChatResponseUpdate[] updates = [new(ChatRole.Assistant, "Hi")];
mockInner.Setup(c => c.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Returns(ToAsyncEnumerableAsync(updates));
TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(100000));
CompactingChatClient client = new(mockInner.Object, strategy);
List<ChatMessage> messages = [new ChatMessage(ChatRole.User, "Hello")];
// Act
List<ChatResponseUpdate> results = [];
await foreach (ChatResponseUpdate update in client.GetStreamingResponseAsync(messages))
{
results.Add(update);
}
// Assert
Assert.Single(results);
Assert.Equal("Hi", results[0].Text);
}
[Fact]
public async Task GetStreamingResponseAsyncWithContextAppliesCompactionAsync()
{
// Arrange
Mock<IChatClient> mockInner = new();
ChatResponseUpdate[] updates = [new(ChatRole.Assistant, "Done")];
mockInner.Setup(c => c.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Returns(ToAsyncEnumerableAsync(updates));
TruncationCompactionStrategy strategy = new(_ => true, minimumPreserved: 1);
CompactingChatClient client = new(mockInner.Object, strategy);
TestAgentSession session = new();
SetRunContext(session);
List<ChatMessage> messages =
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
];
// Act
List<ChatResponseUpdate> results = [];
await foreach (ChatResponseUpdate update in client.GetStreamingResponseAsync(messages))
{
results.Add(update);
}
// Assert
Assert.Single(results);
Assert.Equal("Done", results[0].Text);
}
[Fact]
public void GetServiceReturnsStrategyForMatchingType()
{
// Arrange
Mock<IChatClient> mockInner = new();
TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(1000));
CompactingChatClient client = new(mockInner.Object, strategy);
// Act — typeof(Type).IsInstanceOfType(typeof(CompactionStrategy)) is true
object? result = client.GetService(typeof(Type));
// Assert
Assert.Same(strategy, result);
}
[Fact]
public void GetServiceDelegatesToBaseForNonMatchingType()
{
// Arrange
Mock<IChatClient> mockInner = new();
TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(1000));
CompactingChatClient client = new(mockInner.Object, strategy);
// Act — typeof(string) doesn't match
object? result = client.GetService(typeof(string));
// Assert — delegates to base (which returns null for unregistered types)
Assert.Null(result);
}
[Fact]
public void GetServiceThrowsOnNullType()
{
Mock<IChatClient> mockInner = new();
TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(1000));
CompactingChatClient client = new(mockInner.Object, strategy);
Assert.Throws<ArgumentNullException>(() => client.GetService(null!));
}
[Fact]
public void GetServiceWithServiceKeyDelegatesToBase()
{
// Arrange — non-null serviceKey always delegates
Mock<IChatClient> mockInner = new();
TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(1000));
CompactingChatClient client = new(mockInner.Object, strategy);
// Act
object? result = client.GetService(typeof(Type), serviceKey: "mykey");
// Assert — delegates to base because serviceKey is non-null
Assert.Null(result);
}
[Fact]
public async Task GetResponseAsyncMessagesNotListCreatesListCopyAsync()
{
// Arrange — pass IEnumerable (not List<ChatMessage>) to exercise the list copy branch
ChatResponse expectedResponse = new([new ChatMessage(ChatRole.Assistant, "Hi")]);
Mock<IChatClient> mockInner = new();
mockInner.Setup(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(expectedResponse);
TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(100000));
CompactingChatClient client = new(mockInner.Object, strategy);
TestAgentSession session = new();
SetRunContext(session);
// Use an IEnumerable (not a List) to trigger the copy path
IEnumerable<ChatMessage> messages = [new(ChatRole.User, "Hello")];
// Act
ChatResponse response = await client.GetResponseAsync(messages);
// Assert
Assert.Same(expectedResponse, response);
}
/// <summary>
/// Sets <see cref="AIAgent.CurrentRunContext"/> via reflection.
/// </summary>
private static void SetCurrentRunContext(AgentRunContext? context)
{
FieldInfo? field = typeof(AIAgent).GetField("s_currentContext", BindingFlags.NonPublic | BindingFlags.Static);
Assert.NotNull(field);
object? asyncLocal = field!.GetValue(null);
Assert.NotNull(asyncLocal);
PropertyInfo? valueProp = asyncLocal!.GetType().GetProperty("Value");
Assert.NotNull(valueProp);
valueProp!.SetValue(asyncLocal, context);
}
/// <summary>
/// Creates an <see cref="AgentRunContext"/> with the given session and sets it as the current context.
/// </summary>
private static void SetRunContext(AgentSession? session)
{
Mock<AIAgent> mockAgent = new() { CallBase = true };
AgentRunContext context = new(
mockAgent.Object,
session,
[new(ChatRole.User, "test")],
null);
SetCurrentRunContext(context);
}
private static async IAsyncEnumerable<ChatResponseUpdate> ToAsyncEnumerableAsync(
ChatResponseUpdate[] updates, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
foreach (ChatResponseUpdate update in updates)
{
cancellationToken.ThrowIfCancellationRequested();
yield return update;
await Task.CompletedTask;
}
}
private sealed class TestAgentSession : AgentSession;
}
@@ -0,0 +1,166 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Compaction;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.UnitTests.Compaction;
/// <summary>
/// Contains tests for the <see cref="CompactionStrategy"/> abstract base class.
/// </summary>
public class CompactionStrategyTests
{
[Fact]
public void ConstructorNullTriggerThrows()
{
// Act & Assert
Assert.Throws<ArgumentNullException>(() => new TestStrategy(null!));
}
[Fact]
public async Task CompactAsyncTriggerNotMetReturnsFalseAsync()
{
// Arrange — trigger never fires
TestStrategy strategy = new(_ => false);
MessageIndex index = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]);
// Act
bool result = await strategy.CompactAsync(index);
// Assert
Assert.False(result);
Assert.Equal(0, strategy.ApplyCallCount);
}
[Fact]
public async Task CompactAsyncTriggerMetCallsApplyAsync()
{
// Arrange — trigger always fires
TestStrategy strategy = new(_ => true, applyFunc: _ => true);
MessageIndex index = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]);
// Act
bool result = await strategy.CompactAsync(index);
// Assert
Assert.True(result);
Assert.Equal(1, strategy.ApplyCallCount);
}
[Fact]
public async Task CompactAsyncReturnsFalseWhenApplyReturnsFalseAsync()
{
// Arrange — trigger fires but Apply does nothing
TestStrategy strategy = new(_ => true, applyFunc: _ => false);
MessageIndex index = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]);
// Act
bool result = await strategy.CompactAsync(index);
// Assert
Assert.False(result);
Assert.Equal(1, strategy.ApplyCallCount);
}
[Fact]
public async Task CompactAsyncDefaultTargetIsInverseOfTriggerAsync()
{
// Arrange — trigger fires when groups > 2
// Default target should be: stop when groups <= 2 (i.e., !trigger)
CompactionTrigger trigger = CompactionTriggers.GroupsExceed(2);
TestStrategy strategy = new(trigger, applyFunc: index =>
{
// Exclude oldest non-system group one at a time
foreach (MessageGroup group in index.Groups)
{
if (!group.IsExcluded && group.Kind != MessageGroupKind.System)
{
group.IsExcluded = true;
// Target (default = !trigger) returns true when groups <= 2
// So the strategy would check Target after this exclusion
break;
}
}
return true;
});
MessageIndex index = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
new ChatMessage(ChatRole.Assistant, "A2"),
]);
// Act
bool result = await strategy.CompactAsync(index);
// Assert — trigger fires (4 > 2), Apply is called
Assert.True(result);
Assert.Equal(1, strategy.ApplyCallCount);
}
[Fact]
public async Task CompactAsyncCustomTargetIsPassedToStrategyAsync()
{
// Arrange — custom target that always signals stop
bool targetCalled = false;
CompactionTrigger customTarget = _ =>
{
targetCalled = true;
return true;
};
TestStrategy strategy = new(_ => true, customTarget, _ =>
{
// Access the target from within the strategy
return true;
});
MessageIndex index = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]);
// Act
await strategy.CompactAsync(index);
// Assert — the custom target is accessible (verified by TestStrategy checking it)
Assert.Equal(1, strategy.ApplyCallCount);
// The target is accessible to derived classes via the protected property
Assert.True(strategy.InvokeTarget(index));
Assert.True(targetCalled);
}
/// <summary>
/// A concrete test implementation of <see cref="CompactionStrategy"/> for testing the base class.
/// </summary>
private sealed class TestStrategy : CompactionStrategy
{
private readonly Func<MessageIndex, bool>? _applyFunc;
public TestStrategy(
CompactionTrigger trigger,
CompactionTrigger? target = null,
Func<MessageIndex, bool>? applyFunc = null)
: base(trigger, target)
{
this._applyFunc = applyFunc;
}
public int ApplyCallCount { get; private set; }
/// <summary>
/// Exposes the protected Target property for test verification.
/// </summary>
public bool InvokeTarget(MessageIndex index) => this.Target(index);
protected override Task<bool> ApplyCompactionAsync(MessageIndex index, CancellationToken cancellationToken)
{
this.ApplyCallCount++;
bool result = this._applyFunc?.Invoke(index) ?? false;
return Task.FromResult(result);
}
}
}
@@ -0,0 +1,180 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.Compaction;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.UnitTests.Compaction;
/// <summary>
/// Contains tests for <see cref="CompactionTrigger"/> and <see cref="CompactionTriggers"/>.
/// </summary>
public class CompactionTriggersTests
{
[Fact]
public void TokensExceedReturnsTrueWhenAboveThreshold()
{
// Arrange — use a long message to guarantee tokens > 0
CompactionTrigger trigger = CompactionTriggers.TokensExceed(0);
MessageIndex index = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello world")]);
// Act & Assert
Assert.True(trigger(index));
}
[Fact]
public void TokensExceedReturnsFalseWhenBelowThreshold()
{
CompactionTrigger trigger = CompactionTriggers.TokensExceed(999_999);
MessageIndex index = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hi")]);
Assert.False(trigger(index));
}
[Fact]
public void MessagesExceedReturnsExpectedResult()
{
CompactionTrigger trigger = CompactionTriggers.MessagesExceed(2);
MessageIndex small = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "A"),
new ChatMessage(ChatRole.User, "B"),
]);
MessageIndex large = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "A"),
new ChatMessage(ChatRole.User, "B"),
new ChatMessage(ChatRole.User, "C"),
]);
Assert.False(trigger(small));
Assert.True(trigger(large));
}
[Fact]
public void TurnsExceedReturnsExpectedResult()
{
CompactionTrigger trigger = CompactionTriggers.TurnsExceed(1);
MessageIndex oneTurn = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
]);
MessageIndex twoTurns = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
]);
Assert.False(trigger(oneTurn));
Assert.True(trigger(twoTurns));
}
[Fact]
public void GroupsExceedReturnsExpectedResult()
{
CompactionTrigger trigger = CompactionTriggers.GroupsExceed(2);
MessageIndex index = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "A"),
new ChatMessage(ChatRole.Assistant, "B"),
new ChatMessage(ChatRole.User, "C"),
]);
Assert.True(trigger(index));
}
[Fact]
public void HasToolCallsReturnsTrueWhenToolCallGroupExists()
{
CompactionTrigger trigger = CompactionTriggers.HasToolCalls();
MessageIndex index = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "fn")]),
new ChatMessage(ChatRole.Tool, "result"),
]);
Assert.True(trigger(index));
}
[Fact]
public void HasToolCallsReturnsFalseWhenNoToolCallGroup()
{
CompactionTrigger trigger = CompactionTriggers.HasToolCalls();
MessageIndex index = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Hello"),
new ChatMessage(ChatRole.Assistant, "Hi!"),
]);
Assert.False(trigger(index));
}
[Fact]
public void AllRequiresAllConditions()
{
CompactionTrigger trigger = CompactionTriggers.All(
CompactionTriggers.TokensExceed(0),
CompactionTriggers.MessagesExceed(5));
MessageIndex small = MessageIndex.Create([new ChatMessage(ChatRole.User, "A")]);
// Tokens > 0 is true, but messages > 5 is false
Assert.False(trigger(small));
}
[Fact]
public void AnyRequiresAtLeastOneCondition()
{
CompactionTrigger trigger = CompactionTriggers.Any(
CompactionTriggers.TokensExceed(999_999),
CompactionTriggers.MessagesExceed(0));
MessageIndex index = MessageIndex.Create([new ChatMessage(ChatRole.User, "A")]);
// Tokens not exceeded, but messages > 0 is true
Assert.True(trigger(index));
}
[Fact]
public void AllEmptyTriggersReturnsTrue()
{
CompactionTrigger trigger = CompactionTriggers.All();
MessageIndex index = MessageIndex.Create([new ChatMessage(ChatRole.User, "A")]);
Assert.True(trigger(index));
}
[Fact]
public void AnyEmptyTriggersReturnsFalse()
{
CompactionTrigger trigger = CompactionTriggers.Any();
MessageIndex index = MessageIndex.Create([new ChatMessage(ChatRole.User, "A")]);
Assert.False(trigger(index));
}
[Fact]
public void TokensBelowReturnsTrueWhenBelowThreshold()
{
CompactionTrigger trigger = CompactionTriggers.TokensBelow(999_999);
MessageIndex index = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hi")]);
Assert.True(trigger(index));
}
[Fact]
public void TokensBelowReturnsFalseWhenAboveThreshold()
{
CompactionTrigger trigger = CompactionTriggers.TokensBelow(0);
MessageIndex index = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello world")]);
Assert.False(trigger(index));
}
[Fact]
public void AlwaysReturnsTrue()
{
MessageIndex index = MessageIndex.Create([new ChatMessage(ChatRole.User, "A")]);
Assert.True(CompactionTriggers.Always(index));
}
}
@@ -0,0 +1,911 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Buffers;
using System.Collections.Generic;
using Microsoft.Agents.AI.Compaction;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.UnitTests.Compaction;
/// <summary>
/// Contains tests for the <see cref="MessageIndex"/> class.
/// </summary>
public class MessageIndexTests
{
[Fact]
public void CreateEmptyListReturnsEmptyGroups()
{
// Arrange
List<ChatMessage> messages = [];
// Act
MessageIndex groups = MessageIndex.Create(messages);
// Assert
Assert.Empty(groups.Groups);
}
[Fact]
public void CreateSystemMessageCreatesSystemGroup()
{
// Arrange
List<ChatMessage> messages =
[
new ChatMessage(ChatRole.System, "You are helpful."),
];
// Act
MessageIndex groups = MessageIndex.Create(messages);
// Assert
Assert.Single(groups.Groups);
Assert.Equal(MessageGroupKind.System, groups.Groups[0].Kind);
Assert.Single(groups.Groups[0].Messages);
}
[Fact]
public void CreateUserMessageCreatesUserGroup()
{
// Arrange
List<ChatMessage> messages =
[
new ChatMessage(ChatRole.User, "Hello"),
];
// Act
MessageIndex groups = MessageIndex.Create(messages);
// Assert
Assert.Single(groups.Groups);
Assert.Equal(MessageGroupKind.User, groups.Groups[0].Kind);
}
[Fact]
public void CreateAssistantTextMessageCreatesAssistantTextGroup()
{
// Arrange
List<ChatMessage> messages =
[
new ChatMessage(ChatRole.Assistant, "Hi there!"),
];
// Act
MessageIndex groups = MessageIndex.Create(messages);
// Assert
Assert.Single(groups.Groups);
Assert.Equal(MessageGroupKind.AssistantText, groups.Groups[0].Kind);
}
[Fact]
public void CreateToolCallWithResultsCreatesAtomicToolCallGroup()
{
// Arrange
ChatMessage assistantMessage = new(ChatRole.Assistant, [new FunctionCallContent("call1", "get_weather", new Dictionary<string, object?> { ["city"] = "Seattle" })]);
ChatMessage toolResult = new(ChatRole.Tool, "Sunny, 72°F");
List<ChatMessage> messages = [assistantMessage, toolResult];
// Act
MessageIndex groups = MessageIndex.Create(messages);
// Assert
Assert.Single(groups.Groups);
Assert.Equal(MessageGroupKind.ToolCall, groups.Groups[0].Kind);
Assert.Equal(2, groups.Groups[0].Messages.Count);
Assert.Same(assistantMessage, groups.Groups[0].Messages[0]);
Assert.Same(toolResult, groups.Groups[0].Messages[1]);
}
[Fact]
public void CreateMixedConversationGroupsCorrectly()
{
// Arrange
ChatMessage systemMsg = new(ChatRole.System, "You are helpful.");
ChatMessage userMsg = new(ChatRole.User, "What's the weather?");
ChatMessage assistantToolCall = new(ChatRole.Assistant, [new FunctionCallContent("call1", "get_weather")]);
ChatMessage toolResult = new(ChatRole.Tool, "Sunny");
ChatMessage assistantText = new(ChatRole.Assistant, "The weather is sunny!");
List<ChatMessage> messages = [systemMsg, userMsg, assistantToolCall, toolResult, assistantText];
// Act
MessageIndex groups = MessageIndex.Create(messages);
// Assert
Assert.Equal(4, groups.Groups.Count);
Assert.Equal(MessageGroupKind.System, groups.Groups[0].Kind);
Assert.Equal(MessageGroupKind.User, groups.Groups[1].Kind);
Assert.Equal(MessageGroupKind.ToolCall, groups.Groups[2].Kind);
Assert.Equal(2, groups.Groups[2].Messages.Count);
Assert.Equal(MessageGroupKind.AssistantText, groups.Groups[3].Kind);
}
[Fact]
public void CreateMultipleToolResultsGroupsAllWithAssistant()
{
// Arrange
ChatMessage assistantToolCall = new(ChatRole.Assistant, [
new FunctionCallContent("call1", "get_weather"),
new FunctionCallContent("call2", "get_time"),
]);
ChatMessage toolResult1 = new(ChatRole.Tool, "Sunny");
ChatMessage toolResult2 = new(ChatRole.Tool, "3:00 PM");
List<ChatMessage> messages = [assistantToolCall, toolResult1, toolResult2];
// Act
MessageIndex groups = MessageIndex.Create(messages);
// Assert
Assert.Single(groups.Groups);
Assert.Equal(MessageGroupKind.ToolCall, groups.Groups[0].Kind);
Assert.Equal(3, groups.Groups[0].Messages.Count);
}
[Fact]
public void GetIncludedMessagesExcludesMarkedGroups()
{
// Arrange
ChatMessage msg1 = new(ChatRole.User, "First");
ChatMessage msg2 = new(ChatRole.Assistant, "Response");
ChatMessage msg3 = new(ChatRole.User, "Second");
MessageIndex groups = MessageIndex.Create([msg1, msg2, msg3]);
groups.Groups[1].IsExcluded = true;
// Act
List<ChatMessage> included = [.. groups.GetIncludedMessages()];
// Assert
Assert.Equal(2, included.Count);
Assert.Same(msg1, included[0]);
Assert.Same(msg3, included[1]);
}
[Fact]
public void GetAllMessagesIncludesExcludedGroups()
{
// Arrange
ChatMessage msg1 = new(ChatRole.User, "First");
ChatMessage msg2 = new(ChatRole.Assistant, "Response");
MessageIndex groups = MessageIndex.Create([msg1, msg2]);
groups.Groups[0].IsExcluded = true;
// Act
List<ChatMessage> all = [.. groups.GetAllMessages()];
// Assert
Assert.Equal(2, all.Count);
}
[Fact]
public void IncludedGroupCountReflectsExclusions()
{
// Arrange
MessageIndex groups = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "A"),
new ChatMessage(ChatRole.Assistant, "B"),
new ChatMessage(ChatRole.User, "C"),
]);
groups.Groups[1].IsExcluded = true;
// Act & Assert
Assert.Equal(2, groups.IncludedGroupCount);
Assert.Equal(2, groups.IncludedMessageCount);
}
[Fact]
public void CreateSummaryMessageCreatesSummaryGroup()
{
// Arrange
ChatMessage summaryMessage = new(ChatRole.Assistant, "[Summary of earlier conversation]: key facts...");
(summaryMessage.AdditionalProperties ??= [])[MessageGroup.SummaryPropertyKey] = true;
List<ChatMessage> messages = [summaryMessage];
// Act
MessageIndex groups = MessageIndex.Create(messages);
// Assert
Assert.Single(groups.Groups);
Assert.Equal(MessageGroupKind.Summary, groups.Groups[0].Kind);
Assert.Same(summaryMessage, groups.Groups[0].Messages[0]);
}
[Fact]
public void CreateSummaryAmongOtherMessagesGroupsCorrectly()
{
// Arrange
ChatMessage systemMsg = new(ChatRole.System, "You are helpful.");
ChatMessage summaryMsg = new(ChatRole.Assistant, "[Summary]: previous context");
(summaryMsg.AdditionalProperties ??= [])[MessageGroup.SummaryPropertyKey] = true;
ChatMessage userMsg = new(ChatRole.User, "Continue...");
List<ChatMessage> messages = [systemMsg, summaryMsg, userMsg];
// Act
MessageIndex groups = MessageIndex.Create(messages);
// Assert
Assert.Equal(3, groups.Groups.Count);
Assert.Equal(MessageGroupKind.System, groups.Groups[0].Kind);
Assert.Equal(MessageGroupKind.Summary, groups.Groups[1].Kind);
Assert.Equal(MessageGroupKind.User, groups.Groups[2].Kind);
}
[Fact]
public void MessageGroupStoresPassedCounts()
{
// Arrange & Act
MessageGroup group = new(MessageGroupKind.User, [new ChatMessage(ChatRole.User, "Hello")], byteCount: 5, tokenCount: 2);
// Assert
Assert.Equal(1, group.MessageCount);
Assert.Equal(5, group.ByteCount);
Assert.Equal(2, group.TokenCount);
}
[Fact]
public void MessageGroupMessagesAreImmutable()
{
// Arrange
IReadOnlyList<ChatMessage> messages = [new ChatMessage(ChatRole.User, "Hello")];
MessageGroup group = new(MessageGroupKind.User, messages, byteCount: 5, tokenCount: 1);
// Assert — Messages is IReadOnlyList, not IList
Assert.IsAssignableFrom<IReadOnlyList<ChatMessage>>(group.Messages);
Assert.Same(messages, group.Messages);
}
[Fact]
public void CreateComputesByteCountUtf8()
{
// Arrange — "Hello" is 5 UTF-8 bytes
MessageIndex groups = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]);
// Assert
Assert.Equal(5, groups.Groups[0].ByteCount);
}
[Fact]
public void CreateComputesByteCountMultiByteChars()
{
// Arrange — "café" has a multi-byte 'é' (2 bytes in UTF-8) → 5 bytes total
MessageIndex groups = MessageIndex.Create([new ChatMessage(ChatRole.User, "café")]);
// Assert
Assert.Equal(5, groups.Groups[0].ByteCount);
}
[Fact]
public void CreateComputesByteCountMultipleMessagesInGroup()
{
// Arrange — ToolCall group: assistant (tool call, null text) + tool result "OK" (2 bytes)
ChatMessage assistantMsg = new(ChatRole.Assistant, [new FunctionCallContent("call1", "fn")]);
ChatMessage toolResult = new(ChatRole.Tool, "OK");
MessageIndex groups = MessageIndex.Create([assistantMsg, toolResult]);
// Assert — single ToolCall group with 2 messages
Assert.Single(groups.Groups);
Assert.Equal(2, groups.Groups[0].MessageCount);
Assert.Equal(2, groups.Groups[0].ByteCount); // "OK" = 2 bytes, assistant text is null
}
[Fact]
public void CreateDefaultTokenCountIsHeuristic()
{
// Arrange — "Hello world test data!" = 22 UTF-8 bytes → 22 / 4 = 5 estimated tokens
MessageIndex groups = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello world test data!")]);
// Assert
Assert.Equal(22, groups.Groups[0].ByteCount);
Assert.Equal(22 / 4, groups.Groups[0].TokenCount);
}
[Fact]
public void CreateNullTextHasZeroCounts()
{
// Arrange — message with no text (e.g., pure function call)
ChatMessage msg = new(ChatRole.Assistant, [new FunctionCallContent("call1", "get_weather")]);
ChatMessage tool = new(ChatRole.Tool, string.Empty);
MessageIndex groups = MessageIndex.Create([msg, tool]);
// Assert
Assert.Equal(2, groups.Groups[0].MessageCount);
Assert.Equal(0, groups.Groups[0].ByteCount);
Assert.Equal(0, groups.Groups[0].TokenCount);
}
[Fact]
public void TotalAggregatesSumAllGroups()
{
// Arrange
MessageIndex groups = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "AAAA"), // 4 bytes
new ChatMessage(ChatRole.Assistant, "BBBB"), // 4 bytes
]);
groups.Groups[0].IsExcluded = true;
// Act & Assert — totals include excluded groups
Assert.Equal(2, groups.TotalGroupCount);
Assert.Equal(2, groups.TotalMessageCount);
Assert.Equal(8, groups.TotalByteCount);
Assert.Equal(2, groups.TotalTokenCount); // Each group: 4 bytes / 4 = 1 token, 2 groups = 2
}
[Fact]
public void IncludedAggregatesExcludeMarkedGroups()
{
// Arrange
MessageIndex groups = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "AAAA"), // 4 bytes
new ChatMessage(ChatRole.Assistant, "BBBB"), // 4 bytes
new ChatMessage(ChatRole.User, "CCCC"), // 4 bytes
]);
groups.Groups[0].IsExcluded = true;
// Act & Assert
Assert.Equal(3, groups.TotalGroupCount);
Assert.Equal(2, groups.IncludedGroupCount);
Assert.Equal(3, groups.TotalMessageCount);
Assert.Equal(2, groups.IncludedMessageCount);
Assert.Equal(12, groups.TotalByteCount);
Assert.Equal(8, groups.IncludedByteCount);
Assert.Equal(3, groups.TotalTokenCount); // 12 / 4 = 3 (across 3 groups of 4 bytes each = 1+1+1)
Assert.Equal(2, groups.IncludedTokenCount); // 8 / 4 = 2 (2 included groups of 4 bytes = 1+1)
}
[Fact]
public void ToolCallGroupAggregatesAcrossMessages()
{
// Arrange — tool call group with assistant "Ask" (3 bytes) + tool result "OK" (2 bytes)
ChatMessage assistantMsg = new(ChatRole.Assistant, [new FunctionCallContent("call1", "fn")]);
ChatMessage toolResult = new(ChatRole.Tool, "OK");
MessageIndex groups = MessageIndex.Create([assistantMsg, toolResult]);
// Assert — single group with 2 messages
Assert.Single(groups.Groups);
Assert.Equal(2, groups.Groups[0].MessageCount);
Assert.Equal(2, groups.Groups[0].ByteCount); // assistant text is null (function call), tool result is "OK" = 2 bytes
Assert.Equal(1, groups.TotalGroupCount);
Assert.Equal(2, groups.TotalMessageCount);
}
[Fact]
public void CreateAssignsTurnIndicesSingleTurn()
{
// Arrange — System (no turn), User + Assistant = turn 1
MessageIndex groups = MessageIndex.Create(
[
new ChatMessage(ChatRole.System, "You are helpful."),
new ChatMessage(ChatRole.User, "Hello"),
new ChatMessage(ChatRole.Assistant, "Hi!"),
]);
// Assert
Assert.Null(groups.Groups[0].TurnIndex); // System
Assert.Equal(1, groups.Groups[1].TurnIndex); // User
Assert.Equal(1, groups.Groups[2].TurnIndex); // Assistant
Assert.Equal(1, groups.TotalTurnCount);
Assert.Equal(1, groups.IncludedTurnCount);
}
[Fact]
public void CreateAssignsTurnIndicesMultiTurn()
{
// Arrange — 3 user turns
MessageIndex groups = MessageIndex.Create(
[
new ChatMessage(ChatRole.System, "System prompt."),
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
new ChatMessage(ChatRole.Assistant, "A2"),
new ChatMessage(ChatRole.User, "Q3"),
]);
// Assert — 6 groups: System(null), User(1), Assistant(1), User(2), Assistant(2), User(3)
Assert.Null(groups.Groups[0].TurnIndex);
Assert.Equal(1, groups.Groups[1].TurnIndex);
Assert.Equal(1, groups.Groups[2].TurnIndex);
Assert.Equal(2, groups.Groups[3].TurnIndex);
Assert.Equal(2, groups.Groups[4].TurnIndex);
Assert.Equal(3, groups.Groups[5].TurnIndex);
Assert.Equal(3, groups.TotalTurnCount);
}
[Fact]
public void CreateTurnSpansToolCallGroups()
{
// Arrange — turn 1 includes User, ToolCall, AssistantText
ChatMessage assistantToolCall = new(ChatRole.Assistant, [new FunctionCallContent("call1", "get_weather")]);
ChatMessage toolResult = new(ChatRole.Tool, "Sunny");
MessageIndex groups = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "What's the weather?"),
assistantToolCall,
toolResult,
new ChatMessage(ChatRole.Assistant, "The weather is sunny!"),
]);
// Assert — all 3 groups belong to turn 1
Assert.Equal(3, groups.Groups.Count);
Assert.Equal(1, groups.Groups[0].TurnIndex); // User
Assert.Equal(1, groups.Groups[1].TurnIndex); // ToolCall
Assert.Equal(1, groups.Groups[2].TurnIndex); // AssistantText
Assert.Equal(1, groups.TotalTurnCount);
}
[Fact]
public void GetTurnGroupsReturnsGroupsForSpecificTurn()
{
// Arrange
MessageIndex groups = MessageIndex.Create(
[
new ChatMessage(ChatRole.System, "System."),
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
new ChatMessage(ChatRole.Assistant, "A2"),
]);
// Act
List<MessageGroup> turn1 = [.. groups.GetTurnGroups(1)];
List<MessageGroup> turn2 = [.. groups.GetTurnGroups(2)];
// Assert
Assert.Equal(2, turn1.Count);
Assert.Equal(MessageGroupKind.User, turn1[0].Kind);
Assert.Equal(MessageGroupKind.AssistantText, turn1[1].Kind);
Assert.Equal(2, turn2.Count);
Assert.Equal(MessageGroupKind.User, turn2[0].Kind);
Assert.Equal(MessageGroupKind.AssistantText, turn2[1].Kind);
}
[Fact]
public void IncludedTurnCountReflectsExclusions()
{
// Arrange — 2 turns, exclude all groups in turn 1
MessageIndex groups = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
new ChatMessage(ChatRole.Assistant, "A2"),
]);
groups.Groups[0].IsExcluded = true; // User Q1 (turn 1)
groups.Groups[1].IsExcluded = true; // Assistant A1 (turn 1)
// Assert
Assert.Equal(2, groups.TotalTurnCount);
Assert.Equal(1, groups.IncludedTurnCount); // Only turn 2 has included groups
}
[Fact]
public void TotalTurnCountZeroWhenNoUserMessages()
{
// Arrange — only system messages
MessageIndex groups = MessageIndex.Create(
[
new ChatMessage(ChatRole.System, "System."),
]);
// Assert
Assert.Equal(0, groups.TotalTurnCount);
Assert.Equal(0, groups.IncludedTurnCount);
}
[Fact]
public void IncludedTurnCountPartialExclusionStillCountsTurn()
{
// Arrange — turn 1 has 2 groups, only one excluded
MessageIndex groups = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
]);
groups.Groups[1].IsExcluded = true; // Exclude assistant but user is still included
// Assert — turn 1 still has one included group
Assert.Equal(1, groups.TotalTurnCount);
Assert.Equal(1, groups.IncludedTurnCount);
}
[Fact]
public void UpdateAppendsNewMessagesIncrementally()
{
// Arrange — create with 2 messages
List<ChatMessage> messages =
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
];
MessageIndex index = MessageIndex.Create(messages);
Assert.Equal(2, index.Groups.Count);
Assert.Equal(2, index.ProcessedMessageCount);
// Act — add 2 more messages and update
messages.Add(new ChatMessage(ChatRole.User, "Q2"));
messages.Add(new ChatMessage(ChatRole.Assistant, "A2"));
index.Update(messages);
// Assert — should have 4 groups total, processed count updated
Assert.Equal(4, index.Groups.Count);
Assert.Equal(4, index.ProcessedMessageCount);
Assert.Equal(MessageGroupKind.User, index.Groups[2].Kind);
Assert.Equal(MessageGroupKind.AssistantText, index.Groups[3].Kind);
}
[Fact]
public void UpdateNoOpWhenNoNewMessages()
{
// Arrange
List<ChatMessage> messages =
[
new ChatMessage(ChatRole.User, "Q1"),
];
MessageIndex index = MessageIndex.Create(messages);
int originalCount = index.Groups.Count;
// Act — update with same count
index.Update(messages);
// Assert — nothing changed
Assert.Equal(originalCount, index.Groups.Count);
}
[Fact]
public void UpdateRebuildsWhenMessagesShrink()
{
// Arrange — create with 3 messages
List<ChatMessage> messages =
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
];
MessageIndex index = MessageIndex.Create(messages);
Assert.Equal(3, index.Groups.Count);
// Exclude a group to verify rebuild clears state
index.Groups[0].IsExcluded = true;
// Act — update with fewer messages (simulates storage compaction)
List<ChatMessage> shortened =
[
new ChatMessage(ChatRole.User, "Q2"),
];
index.Update(shortened);
// Assert — rebuilt from scratch
Assert.Single(index.Groups);
Assert.False(index.Groups[0].IsExcluded);
Assert.Equal(1, index.ProcessedMessageCount);
}
[Fact]
public void UpdatePreservesExistingGroupExclusionState()
{
// Arrange
List<ChatMessage> messages =
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
];
MessageIndex index = MessageIndex.Create(messages);
index.Groups[0].IsExcluded = true;
index.Groups[0].ExcludeReason = "Test exclusion";
// Act — append new messages
messages.Add(new ChatMessage(ChatRole.User, "Q2"));
index.Update(messages);
// Assert — original exclusion state preserved
Assert.True(index.Groups[0].IsExcluded);
Assert.Equal("Test exclusion", index.Groups[0].ExcludeReason);
Assert.Equal(3, index.Groups.Count);
}
[Fact]
public void InsertGroupInsertsAtSpecifiedIndex()
{
// Arrange
MessageIndex index = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.User, "Q2"),
]);
// Act — insert between Q1 and Q2
ChatMessage summaryMsg = new(ChatRole.Assistant, "[Summary]");
MessageGroup inserted = index.InsertGroup(1, MessageGroupKind.Summary, [summaryMsg], turnIndex: 1);
// Assert
Assert.Equal(3, index.Groups.Count);
Assert.Same(inserted, index.Groups[1]);
Assert.Equal(MessageGroupKind.Summary, index.Groups[1].Kind);
Assert.Equal("[Summary]", index.Groups[1].Messages[0].Text);
Assert.Equal(1, inserted.TurnIndex);
}
[Fact]
public void AddGroupAppendsToEnd()
{
// Arrange
MessageIndex index = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
]);
// Act
ChatMessage msg = new(ChatRole.Assistant, "Appended");
MessageGroup added = index.AddGroup(MessageGroupKind.AssistantText, [msg], turnIndex: 1);
// Assert
Assert.Equal(2, index.Groups.Count);
Assert.Same(added, index.Groups[1]);
Assert.Equal("Appended", index.Groups[1].Messages[0].Text);
}
[Fact]
public void InsertGroupComputesByteAndTokenCounts()
{
// Arrange
MessageIndex index = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
]);
// Act — insert a group with known text
ChatMessage msg = new(ChatRole.Assistant, "Hello"); // 5 bytes, ~1 token (5/4)
MessageGroup inserted = index.InsertGroup(0, MessageGroupKind.AssistantText, [msg]);
// Assert
Assert.Equal(5, inserted.ByteCount);
Assert.Equal(1, inserted.TokenCount); // 5 / 4 = 1 (integer division)
}
[Fact]
public void ConstructorWithGroupsRestoresTurnIndex()
{
// Arrange — pre-existing groups with turn indices
MessageGroup group1 = new(MessageGroupKind.User, [new ChatMessage(ChatRole.User, "Q1")], 2, 1, turnIndex: 1);
MessageGroup group2 = new(MessageGroupKind.AssistantText, [new ChatMessage(ChatRole.Assistant, "A1")], 2, 1, turnIndex: 1);
MessageGroup group3 = new(MessageGroupKind.User, [new ChatMessage(ChatRole.User, "Q2")], 2, 1, turnIndex: 2);
List<MessageGroup> groups = [group1, group2, group3];
// Act — constructor should restore _currentTurn from the last group's TurnIndex
MessageIndex index = new(groups);
// Assert — adding a new user message should get turn 3 (restored 2 + 1)
index.Update(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
new ChatMessage(ChatRole.User, "Q3"),
]);
// The new user group should have TurnIndex 3
MessageGroup lastGroup = index.Groups[index.Groups.Count - 1];
Assert.Equal(MessageGroupKind.User, lastGroup.Kind);
Assert.NotNull(lastGroup.TurnIndex);
}
[Fact]
public void ConstructorWithEmptyGroupsHandlesGracefully()
{
// Arrange & Act — constructor with empty list
MessageIndex index = new([]);
// Assert
Assert.Empty(index.Groups);
}
[Fact]
public void ConstructorWithGroupsWithoutTurnIndexSkipsRestore()
{
// Arrange — groups without turn indices (system messages)
MessageGroup systemGroup = new(MessageGroupKind.System, [new ChatMessage(ChatRole.System, "Be helpful")], 10, 3, turnIndex: null);
List<MessageGroup> groups = [systemGroup];
// Act — constructor won't find a TurnIndex to restore
MessageIndex index = new(groups);
// Assert
Assert.Single(index.Groups);
}
[Fact]
public void ComputeTokenCountReturnsTokenCount()
{
// Arrange — call the public static method directly
List<ChatMessage> messages =
[
new ChatMessage(ChatRole.User, "Hello world"),
new ChatMessage(ChatRole.Assistant, "Greetings"),
];
// Act — use a simple tokenizer that counts words (each word = 1 token)
SimpleWordTokenizer tokenizer = new();
int tokenCount = MessageIndex.ComputeTokenCount(messages, tokenizer);
// Assert — "Hello world" = 2, "Greetings" = 1 → 3 total
Assert.Equal(3, tokenCount);
}
[Fact]
public void ComputeTokenCountEmptyTextReturnsZero()
{
// Arrange — message with no text content
List<ChatMessage> messages =
[
new ChatMessage(ChatRole.User, [new FunctionCallContent("c1", "fn")]),
];
SimpleWordTokenizer tokenizer = new();
int tokenCount = MessageIndex.ComputeTokenCount(messages, tokenizer);
// Assert — no text content → 0 tokens
Assert.Equal(0, tokenCount);
}
[Fact]
public void CreateWithTokenizerUsesTokenizerForCounts()
{
// Arrange
SimpleWordTokenizer tokenizer = new();
List<ChatMessage> messages =
[
new ChatMessage(ChatRole.User, "Hello world test"),
];
// Act
MessageIndex index = MessageIndex.Create(messages, tokenizer);
// Assert — tokenizer counts words: "Hello world test" = 3 tokens
Assert.Single(index.Groups);
Assert.Equal(3, index.Groups[0].TokenCount);
Assert.NotNull(index.Tokenizer);
}
[Fact]
public void InsertGroupWithTokenizerUsesTokenizer()
{
// Arrange
SimpleWordTokenizer tokenizer = new();
MessageIndex index = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Hello"),
], tokenizer);
// Act
ChatMessage msg = new(ChatRole.Assistant, "Hello world test message");
MessageGroup inserted = index.InsertGroup(0, MessageGroupKind.AssistantText, [msg]);
// Assert — tokenizer counts words: "Hello world test message" = 4 tokens
Assert.Equal(4, inserted.TokenCount);
}
[Fact]
public void CreateWithStandaloneToolMessageGroupsAsAssistantText()
{
// A Tool message not preceded by an assistant tool-call falls through to the else branch
List<ChatMessage> messages =
[
new ChatMessage(ChatRole.Tool, "Orphaned tool result"),
];
MessageIndex index = MessageIndex.Create(messages);
// The Tool message should be grouped as AssistantText (the default fallback)
Assert.Single(index.Groups);
Assert.Equal(MessageGroupKind.AssistantText, index.Groups[0].Kind);
}
[Fact]
public void CreateWithAssistantNonSummaryWithPropertiesFallsToAssistantText()
{
// Assistant message with AdditionalProperties but NOT a summary
ChatMessage assistant = new(ChatRole.Assistant, "Regular response");
(assistant.AdditionalProperties ??= [])["someOtherKey"] = "value";
MessageIndex index = MessageIndex.Create([assistant]);
Assert.Single(index.Groups);
Assert.Equal(MessageGroupKind.AssistantText, index.Groups[0].Kind);
}
[Fact]
public void ComputeByteCountHandlesNullAndNonNullText()
{
// Mix of messages: one with text (non-null), one without (null Text)
List<ChatMessage> messages =
[
new ChatMessage(ChatRole.User, "Hello"),
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "fn")]),
];
int byteCount = MessageIndex.ComputeByteCount(messages);
// Only "Hello" contributes bytes (5 bytes UTF-8)
Assert.Equal(5, byteCount);
}
[Fact]
public void ComputeTokenCountHandlesNullAndNonNullText()
{
// Mix: one with text, one without
SimpleWordTokenizer tokenizer = new();
List<ChatMessage> messages =
[
new ChatMessage(ChatRole.User, "Hello world"),
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "fn")]),
];
int tokenCount = MessageIndex.ComputeTokenCount(messages, tokenizer);
// Only "Hello world" contributes tokens (2 words)
Assert.Equal(2, tokenCount);
}
/// <summary>
/// A simple tokenizer that counts whitespace-separated words as tokens.
/// </summary>
private sealed class SimpleWordTokenizer : Microsoft.ML.Tokenizers.Tokenizer
{
public override Microsoft.ML.Tokenizers.PreTokenizer? PreTokenizer => null;
public override Microsoft.ML.Tokenizers.Normalizer? Normalizer => null;
protected override Microsoft.ML.Tokenizers.EncodeResults<Microsoft.ML.Tokenizers.EncodedToken> EncodeToTokens(string? text, System.ReadOnlySpan<char> textSpan, Microsoft.ML.Tokenizers.EncodeSettings settings)
{
// Simple word-based encoding
string input = text ?? textSpan.ToString();
if (string.IsNullOrWhiteSpace(input))
{
return new Microsoft.ML.Tokenizers.EncodeResults<Microsoft.ML.Tokenizers.EncodedToken>
{
Tokens = System.Array.Empty<Microsoft.ML.Tokenizers.EncodedToken>(),
CharsConsumed = 0,
NormalizedText = null,
};
}
string[] words = input.Split(' ');
List<Microsoft.ML.Tokenizers.EncodedToken> tokens = [];
int offset = 0;
for (int i = 0; i < words.Length; i++)
{
tokens.Add(new Microsoft.ML.Tokenizers.EncodedToken(i, words[i], new System.Range(offset, offset + words[i].Length)));
offset += words[i].Length + 1;
}
return new Microsoft.ML.Tokenizers.EncodeResults<Microsoft.ML.Tokenizers.EncodedToken>
{
Tokens = tokens,
CharsConsumed = input.Length,
NormalizedText = null,
};
}
public override OperationStatus Decode(System.Collections.Generic.IEnumerable<int> ids, System.Span<char> destination, out int idsConsumed, out int charsWritten)
{
idsConsumed = 0;
charsWritten = 0;
return OperationStatus.Done;
}
}
}
@@ -0,0 +1,191 @@
// 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;
namespace Microsoft.Agents.AI.UnitTests.Compaction;
/// <summary>
/// Contains tests for the <see cref="PipelineCompactionStrategy"/> class.
/// </summary>
public class PipelineCompactionStrategyTests
{
[Fact]
public async Task CompactAsyncExecutesAllStrategiesInOrderAsync()
{
// Arrange
List<string> executionOrder = [];
TestCompactionStrategy strategy1 = new(
_ =>
{
executionOrder.Add("first");
return false;
});
TestCompactionStrategy strategy2 = new(
_ =>
{
executionOrder.Add("second");
return false;
});
PipelineCompactionStrategy pipeline = new(strategy1, strategy2);
MessageIndex groups = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]);
// Act
await pipeline.CompactAsync(groups);
// Assert
Assert.Equal(["first", "second"], executionOrder);
}
[Fact]
public async Task CompactAsyncReturnsFalseWhenNoStrategyCompactsAsync()
{
// Arrange
TestCompactionStrategy strategy1 = new(_ => false);
PipelineCompactionStrategy pipeline = new(strategy1);
MessageIndex groups = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]);
// Act
bool result = await pipeline.CompactAsync(groups);
// Assert
Assert.False(result);
}
[Fact]
public async Task CompactAsyncReturnsTrueWhenAnyStrategyCompactsAsync()
{
// Arrange
TestCompactionStrategy strategy1 = new(_ => false);
TestCompactionStrategy strategy2 = new(_ => true);
PipelineCompactionStrategy pipeline = new(strategy1, strategy2);
MessageIndex groups = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]);
// Act
bool result = await pipeline.CompactAsync(groups);
// Assert
Assert.True(result);
}
[Fact]
public async Task CompactAsyncContinuesAfterFirstCompactionAsync()
{
// Arrange
TestCompactionStrategy strategy1 = new(_ => true);
TestCompactionStrategy strategy2 = new(_ => false);
PipelineCompactionStrategy pipeline = new(strategy1, strategy2);
MessageIndex groups = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]);
// Act
await pipeline.CompactAsync(groups);
// Assert — both strategies were called
Assert.Equal(1, strategy1.ApplyCallCount);
Assert.Equal(1, strategy2.ApplyCallCount);
}
[Fact]
public async Task CompactAsyncComposesStrategiesEndToEndAsync()
{
// Arrange — pipeline: first exclude oldest 2 non-system groups, then exclude 2 more
static void ExcludeOldest2(MessageIndex index)
{
int excluded = 0;
foreach (MessageGroup group in index.Groups)
{
if (!group.IsExcluded && group.Kind != MessageGroupKind.System && excluded < 2)
{
group.IsExcluded = true;
excluded++;
}
}
}
TestCompactionStrategy phase1 = new(
index =>
{
ExcludeOldest2(index);
return true;
});
TestCompactionStrategy phase2 = new(
index =>
{
ExcludeOldest2(index);
return true;
});
PipelineCompactionStrategy pipeline = new(phase1, phase2);
MessageIndex groups = MessageIndex.Create(
[
new ChatMessage(ChatRole.System, "You are helpful."),
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
new ChatMessage(ChatRole.Assistant, "A2"),
new ChatMessage(ChatRole.User, "Q3"),
]);
// Act
bool result = await pipeline.CompactAsync(groups);
// Assert — system is preserved, phase1 excluded Q1+A1, phase2 excluded Q2+A2 → System + Q3
Assert.True(result);
Assert.Equal(2, groups.IncludedGroupCount);
List<ChatMessage> included = [.. groups.GetIncludedMessages()];
Assert.Equal(2, included.Count);
Assert.Equal("You are helpful.", included[0].Text);
Assert.Equal("Q3", included[1].Text);
Assert.Equal(1, phase1.ApplyCallCount);
Assert.Equal(1, phase2.ApplyCallCount);
}
[Fact]
public async Task CompactAsyncEmptyPipelineReturnsFalseAsync()
{
// Arrange
PipelineCompactionStrategy pipeline = new(new List<CompactionStrategy>());
MessageIndex groups = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]);
// Act
bool result = await pipeline.CompactAsync(groups);
// Assert
Assert.False(result);
}
/// <summary>
/// A simple test implementation of <see cref="CompactionStrategy"/> that delegates to a synchronous callback.
/// </summary>
private sealed class TestCompactionStrategy : CompactionStrategy
{
private readonly Func<MessageIndex, bool> _applyFunc;
public TestCompactionStrategy(Func<MessageIndex, bool> applyFunc)
: base(CompactionTriggers.Always)
{
this._applyFunc = applyFunc;
}
public int ApplyCallCount { get; private set; }
protected override Task<bool> ApplyCompactionAsync(MessageIndex index, CancellationToken cancellationToken)
{
this.ApplyCallCount++;
return Task.FromResult(this._applyFunc(index));
}
}
}
@@ -0,0 +1,250 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Compaction;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.UnitTests.Compaction;
/// <summary>
/// Contains tests for the <see cref="SlidingWindowCompactionStrategy"/> class.
/// </summary>
public class SlidingWindowCompactionStrategyTests
{
[Fact]
public async Task CompactAsyncBelowMaxTurnsReturnsFalseAsync()
{
// Arrange — trigger requires > 3 turns, conversation has 2
SlidingWindowCompactionStrategy strategy = new(CompactionTriggers.TurnsExceed(3));
MessageIndex groups = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
new ChatMessage(ChatRole.Assistant, "A2"),
]);
// Act
bool result = await strategy.CompactAsync(groups);
// Assert
Assert.False(result);
}
[Fact]
public async Task CompactAsyncExceedsMaxTurnsExcludesOldestTurnsAsync()
{
// Arrange — trigger on > 2 turns, conversation has 3
SlidingWindowCompactionStrategy strategy = new(CompactionTriggers.TurnsExceed(2));
MessageIndex groups = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
new ChatMessage(ChatRole.Assistant, "A2"),
new ChatMessage(ChatRole.User, "Q3"),
new ChatMessage(ChatRole.Assistant, "A3"),
]);
// Act
bool result = await strategy.CompactAsync(groups);
// Assert
Assert.True(result);
// Turn 1 (Q1 + A1) should be excluded
Assert.True(groups.Groups[0].IsExcluded);
Assert.True(groups.Groups[1].IsExcluded);
// Turn 2 and 3 should remain
Assert.False(groups.Groups[2].IsExcluded);
Assert.False(groups.Groups[3].IsExcluded);
Assert.False(groups.Groups[4].IsExcluded);
Assert.False(groups.Groups[5].IsExcluded);
}
[Fact]
public async Task CompactAsyncPreservesSystemMessagesAsync()
{
// Arrange — trigger on > 1 turn
SlidingWindowCompactionStrategy strategy = new(CompactionTriggers.TurnsExceed(1));
MessageIndex groups = MessageIndex.Create(
[
new ChatMessage(ChatRole.System, "You are helpful."),
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
]);
// Act
bool result = await strategy.CompactAsync(groups);
// Assert
Assert.True(result);
Assert.False(groups.Groups[0].IsExcluded); // System preserved
Assert.True(groups.Groups[1].IsExcluded); // Turn 1 excluded
Assert.True(groups.Groups[2].IsExcluded); // Turn 1 response excluded
Assert.False(groups.Groups[3].IsExcluded); // Turn 2 kept
}
[Fact]
public async Task CompactAsyncPreservesToolCallGroupsInKeptTurnsAsync()
{
// Arrange — trigger on > 1 turn
SlidingWindowCompactionStrategy strategy = new(CompactionTriggers.TurnsExceed(1));
MessageIndex groups = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", "search")]),
new ChatMessage(ChatRole.Tool, "Results"),
]);
// Act
bool result = await strategy.CompactAsync(groups);
// Assert
Assert.True(result);
// Turn 1 excluded
Assert.True(groups.Groups[0].IsExcluded);
Assert.True(groups.Groups[1].IsExcluded);
// Turn 2 kept (user + tool call group)
Assert.False(groups.Groups[2].IsExcluded);
Assert.False(groups.Groups[3].IsExcluded);
}
[Fact]
public async Task CompactAsyncTriggerNotMetReturnsFalseAsync()
{
// Arrange — trigger requires > 99 turns
SlidingWindowCompactionStrategy strategy = new(CompactionTriggers.TurnsExceed(99));
MessageIndex groups = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.User, "Q2"),
new ChatMessage(ChatRole.User, "Q3"),
]);
// Act
bool result = await strategy.CompactAsync(groups);
// Assert
Assert.False(result);
}
[Fact]
public async Task CompactAsyncIncludedMessagesContainOnlyKeptTurnsAsync()
{
// Arrange — trigger on > 1 turn
SlidingWindowCompactionStrategy strategy = new(CompactionTriggers.TurnsExceed(1));
MessageIndex groups = MessageIndex.Create(
[
new ChatMessage(ChatRole.System, "System"),
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
new ChatMessage(ChatRole.Assistant, "A2"),
]);
// Act
await strategy.CompactAsync(groups);
// Assert
List<ChatMessage> included = [.. groups.GetIncludedMessages()];
Assert.Equal(3, included.Count);
Assert.Equal("System", included[0].Text);
Assert.Equal("Q2", included[1].Text);
Assert.Equal("A2", included[2].Text);
}
[Fact]
public async Task CompactAsyncCustomTargetStopsExcludingEarlyAsync()
{
// Arrange — trigger on > 1 turn, custom target stops after removing 1 turn
int removeCount = 0;
bool TargetAfterOne(MessageIndex _) => ++removeCount >= 1;
SlidingWindowCompactionStrategy strategy = new(
CompactionTriggers.TurnsExceed(1),
minimumPreserved: 0,
target: TargetAfterOne);
MessageIndex index = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
new ChatMessage(ChatRole.Assistant, "A2"),
new ChatMessage(ChatRole.User, "Q3"),
new ChatMessage(ChatRole.Assistant, "A3"),
new ChatMessage(ChatRole.User, "Q4"),
]);
// Act
bool result = await strategy.CompactAsync(index);
// Assert — only turn 1 excluded (target stopped after 1 removal)
Assert.True(result);
Assert.True(index.Groups[0].IsExcluded); // Q1 (turn 1)
Assert.True(index.Groups[1].IsExcluded); // A1 (turn 1)
Assert.False(index.Groups[2].IsExcluded); // Q2 (turn 2) — kept
Assert.False(index.Groups[3].IsExcluded); // A2 (turn 2)
}
[Fact]
public async Task CompactAsyncMinimumPreservedStopsCompactionAsync()
{
// Arrange — always trigger with never-satisfied target, but MinimumPreserved = 2 is hard floor
SlidingWindowCompactionStrategy strategy = new(
CompactionTriggers.TurnsExceed(1),
minimumPreserved: 2,
target: _ => false);
MessageIndex index = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
new ChatMessage(ChatRole.Assistant, "A2"),
new ChatMessage(ChatRole.User, "Q3"),
new ChatMessage(ChatRole.Assistant, "A3"),
]);
// Act
bool result = await strategy.CompactAsync(index);
// Assert — target never says stop, but MinimumPreserved=2 prevents removing the last 2 groups
Assert.True(result);
Assert.Equal(2, index.IncludedGroupCount);
// Last 2 non-system groups must be preserved
Assert.False(index.Groups[4].IsExcluded); // Q3
Assert.False(index.Groups[5].IsExcluded); // A3
}
[Fact]
public async Task CompactAsyncSkipsExcludedAndSystemGroupsInEnumerationAsync()
{
// Arrange — includes system and pre-excluded groups that must be skipped
SlidingWindowCompactionStrategy strategy = new(
CompactionTriggers.TurnsExceed(1),
minimumPreserved: 0);
MessageIndex index = MessageIndex.Create(
[
new ChatMessage(ChatRole.System, "System prompt"),
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
]);
// Pre-exclude one group
index.Groups[1].IsExcluded = true;
// Act
bool result = await strategy.CompactAsync(index);
// Assert — system preserved, pre-excluded skipped
Assert.True(result);
Assert.False(index.Groups[0].IsExcluded); // System preserved
}
}
@@ -0,0 +1,410 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Linq;
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 the <see cref="SummarizationCompactionStrategy"/> class.
/// </summary>
public class SummarizationCompactionStrategyTests
{
/// <summary>
/// Creates a mock <see cref="IChatClient"/> that returns the specified summary text.
/// </summary>
private static IChatClient CreateMockChatClient(string summaryText = "Summary of conversation.")
{
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, summaryText)]));
return mock.Object;
}
[Fact]
public async Task CompactAsyncTriggerNotMetReturnsFalseAsync()
{
// Arrange — trigger requires > 100000 tokens
SummarizationCompactionStrategy strategy = new(
CreateMockChatClient(),
CompactionTriggers.TokensExceed(100000),
minimumPreserved: 1);
MessageIndex index = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Hello"),
new ChatMessage(ChatRole.Assistant, "Hi!"),
]);
// Act
bool result = await strategy.CompactAsync(index);
// Assert
Assert.False(result);
Assert.Equal(2, index.IncludedGroupCount);
}
[Fact]
public async Task CompactAsyncSummarizesOldGroupsAsync()
{
// Arrange — always trigger, preserve 1 recent group
SummarizationCompactionStrategy strategy = new(
CreateMockChatClient("Key facts from earlier."),
CompactionTriggers.Always,
minimumPreserved: 1);
MessageIndex index = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "First question"),
new ChatMessage(ChatRole.Assistant, "First answer"),
new ChatMessage(ChatRole.User, "Second question"),
]);
// Act
bool result = await strategy.CompactAsync(index);
// Assert
Assert.True(result);
List<ChatMessage> included = [.. index.GetIncludedMessages()];
// Should have: summary + preserved recent group (Second question)
Assert.Equal(2, included.Count);
Assert.Contains("[Summary]", included[0].Text);
Assert.Contains("Key facts from earlier.", included[0].Text);
Assert.Equal("Second question", included[1].Text);
}
[Fact]
public async Task CompactAsyncPreservesSystemMessagesAsync()
{
// Arrange
SummarizationCompactionStrategy strategy = new(
CreateMockChatClient(),
CompactionTriggers.Always,
minimumPreserved: 1);
MessageIndex index = MessageIndex.Create(
[
new ChatMessage(ChatRole.System, "You are helpful."),
new ChatMessage(ChatRole.User, "Old question"),
new ChatMessage(ChatRole.Assistant, "Old answer"),
new ChatMessage(ChatRole.User, "Recent question"),
]);
// Act
await strategy.CompactAsync(index);
// Assert
List<ChatMessage> included = [.. index.GetIncludedMessages()];
Assert.Equal("You are helpful.", included[0].Text);
Assert.Equal(ChatRole.System, included[0].Role);
}
[Fact]
public async Task CompactAsyncInsertsSummaryGroupAtCorrectPositionAsync()
{
// Arrange
SummarizationCompactionStrategy strategy = new(
CreateMockChatClient("Summary text."),
CompactionTriggers.Always,
minimumPreserved: 1);
MessageIndex index = MessageIndex.Create(
[
new ChatMessage(ChatRole.System, "System prompt."),
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
]);
// Act
await strategy.CompactAsync(index);
// Assert — summary should be inserted after system, before preserved group
MessageGroup summaryGroup = index.Groups.First(g => g.Kind == MessageGroupKind.Summary);
Assert.NotNull(summaryGroup);
Assert.Contains("[Summary]", summaryGroup.Messages[0].Text);
Assert.True(summaryGroup.Messages[0].AdditionalProperties!.ContainsKey(MessageGroup.SummaryPropertyKey));
}
[Fact]
public async Task CompactAsyncHandlesEmptyLlmResponseAsync()
{
// Arrange — LLM returns whitespace
SummarizationCompactionStrategy strategy = new(
CreateMockChatClient(" "),
CompactionTriggers.Always,
minimumPreserved: 1);
MessageIndex index = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.User, "Q2"),
]);
// Act
await strategy.CompactAsync(index);
// Assert — should use fallback text
List<ChatMessage> included = [.. index.GetIncludedMessages()];
Assert.Contains("[Summary unavailable]", included[0].Text);
}
[Fact]
public async Task CompactAsyncNothingToSummarizeReturnsFalseAsync()
{
// Arrange — preserve 5 but only 2 non-system groups
SummarizationCompactionStrategy strategy = new(
CreateMockChatClient(),
CompactionTriggers.Always,
minimumPreserved: 5);
MessageIndex index = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Hello"),
new ChatMessage(ChatRole.Assistant, "Hi!"),
]);
// Act
bool result = await strategy.CompactAsync(index);
// Assert
Assert.False(result);
}
[Fact]
public async Task CompactAsyncUsesCustomPromptAsync()
{
// Arrange — capture the messages sent to the chat client
List<ChatMessage>? capturedMessages = null;
Mock<IChatClient> mockClient = new();
mockClient.Setup(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Callback<IEnumerable<ChatMessage>, ChatOptions?, CancellationToken>((msgs, _, _) =>
capturedMessages = [.. msgs])
.ReturnsAsync(new ChatResponse([new ChatMessage(ChatRole.Assistant, "Custom summary.")]));
const string CustomPrompt = "Summarize in bullet points only.";
SummarizationCompactionStrategy strategy = new(
mockClient.Object,
CompactionTriggers.Always,
minimumPreserved: 1,
summarizationPrompt: CustomPrompt);
MessageIndex index = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.User, "Q2"),
]);
// Act
await strategy.CompactAsync(index);
// Assert — the custom prompt should be the first message sent to the LLM
Assert.NotNull(capturedMessages);
Assert.Equal(CustomPrompt, capturedMessages![0].Text);
}
[Fact]
public async Task CompactAsyncSetsExcludeReasonAsync()
{
// Arrange
SummarizationCompactionStrategy strategy = new(
CreateMockChatClient(),
CompactionTriggers.Always,
minimumPreserved: 1);
MessageIndex index = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Old"),
new ChatMessage(ChatRole.User, "New"),
]);
// Act
await strategy.CompactAsync(index);
// Assert
MessageGroup excluded = index.Groups.First(g => g.IsExcluded);
Assert.NotNull(excluded.ExcludeReason);
Assert.Contains("SummarizationCompactionStrategy", excluded.ExcludeReason);
}
[Fact]
public async Task CompactAsyncTargetStopsMarkingEarlyAsync()
{
// Arrange — 4 non-system groups, preserve 1, target met after 1 exclusion
int exclusionCount = 0;
bool TargetAfterOne(MessageIndex _) => ++exclusionCount >= 1;
SummarizationCompactionStrategy strategy = new(
CreateMockChatClient("Partial summary."),
CompactionTriggers.Always,
minimumPreserved: 1,
target: TargetAfterOne);
MessageIndex index = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
new ChatMessage(ChatRole.User, "Q3"),
]);
// Act
await strategy.CompactAsync(index);
// Assert — only 1 group should have been summarized (target met after first exclusion)
int excludedCount = index.Groups.Count(g => g.IsExcluded);
Assert.Equal(1, excludedCount);
}
[Fact]
public async Task CompactAsyncPreservesMultipleRecentGroupsAsync()
{
// Arrange — preserve 2
SummarizationCompactionStrategy strategy = new(
CreateMockChatClient("Summary."),
CompactionTriggers.Always,
minimumPreserved: 2);
MessageIndex index = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
new ChatMessage(ChatRole.Assistant, "A2"),
]);
// Act
await strategy.CompactAsync(index);
// Assert — 2 oldest excluded, 2 newest preserved + 1 summary inserted
List<ChatMessage> included = [.. index.GetIncludedMessages()];
Assert.Equal(3, included.Count); // summary + Q2 + A2
Assert.Contains("[Summary]", included[0].Text);
Assert.Equal("Q2", included[1].Text);
Assert.Equal("A2", included[2].Text);
}
[Fact]
public async Task CompactAsyncWithSystemBetweenSummarizableGroupsAsync()
{
// Arrange — system group between user/assistant groups to exercise skip logic in loop
IChatClient mockClient = CreateMockChatClient("[Summary]");
SummarizationCompactionStrategy strategy = new(
mockClient,
CompactionTriggers.Always,
minimumPreserved: 1);
MessageIndex index = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.System, "System note"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
]);
// Act
bool result = await strategy.CompactAsync(index);
// Assert — summary inserted at 0, system group shifted to index 2
Assert.True(result);
Assert.Equal(MessageGroupKind.Summary, index.Groups[0].Kind);
Assert.Equal(MessageGroupKind.System, index.Groups[2].Kind);
Assert.False(index.Groups[2].IsExcluded); // System never excluded
}
[Fact]
public async Task CompactAsyncMaxSummarizableBoundsLoopExitAsync()
{
// Arrange — large MinimumPreserved so maxSummarizable is small, target never stops
IChatClient mockClient = CreateMockChatClient("[Summary]");
SummarizationCompactionStrategy strategy = new(
mockClient,
CompactionTriggers.Always,
minimumPreserved: 3,
target: _ => false);
MessageIndex index = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
new ChatMessage(ChatRole.Assistant, "A2"),
new ChatMessage(ChatRole.User, "Q3"),
new ChatMessage(ChatRole.Assistant, "A3"),
]);
// Act — should only summarize 6-3 = 3 groups (not all 6)
bool result = await strategy.CompactAsync(index);
// Assert — 3 preserved + 1 summary = 4 included
Assert.True(result);
Assert.Equal(4, index.IncludedGroupCount);
}
[Fact]
public async Task CompactAsyncWithPreExcludedGroupAsync()
{
// Arrange — pre-exclude a group so the count and loop both must skip it
IChatClient mockClient = CreateMockChatClient("[Summary]");
SummarizationCompactionStrategy strategy = new(
mockClient,
CompactionTriggers.Always,
minimumPreserved: 1);
MessageIndex index = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
new ChatMessage(ChatRole.Assistant, "A2"),
]);
index.Groups[0].IsExcluded = true; // Pre-exclude Q1
// Act
bool result = await strategy.CompactAsync(index);
// Assert
Assert.True(result);
Assert.True(index.Groups[0].IsExcluded); // Still excluded
}
[Fact]
public async Task CompactAsyncWithEmptyTextMessageInGroupAsync()
{
// Arrange — a message with null text (FunctionCallContent) in a summarized group
IChatClient mockClient = CreateMockChatClient("[Summary]");
SummarizationCompactionStrategy strategy = new(
mockClient,
CompactionTriggers.Always,
minimumPreserved: 1);
List<ChatMessage> messages =
[
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "fn")]),
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
];
MessageIndex index = MessageIndex.Create(messages);
// Act — the tool-call group's message has null text
bool result = await strategy.CompactAsync(index);
// Assert — compaction succeeded despite null text
Assert.True(result);
}
}
@@ -0,0 +1,262 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Compaction;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.UnitTests.Compaction;
/// <summary>
/// Contains tests for the <see cref="ToolResultCompactionStrategy"/> class.
/// </summary>
public class ToolResultCompactionStrategyTests
{
[Fact]
public async Task CompactAsyncTriggerNotMetReturnsFalseAsync()
{
// Arrange — trigger requires > 1000 tokens
ToolResultCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(1000));
ChatMessage toolCall = new(ChatRole.Assistant, [new FunctionCallContent("call1", "get_weather")]);
ChatMessage toolResult = new(ChatRole.Tool, "Sunny");
MessageIndex groups = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "What's the weather?"),
toolCall,
toolResult,
]);
// Act
bool result = await strategy.CompactAsync(groups);
// Assert
Assert.False(result);
}
[Fact]
public async Task CompactAsyncCollapsesOldToolGroupsAsync()
{
// Arrange — always trigger
ToolResultCompactionStrategy strategy = new(
trigger: _ => true,
minimumPreserved: 1);
MessageIndex groups = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", "get_weather")]),
new ChatMessage(ChatRole.Tool, "Sunny and 72°F"),
new ChatMessage(ChatRole.User, "Q2"),
]);
// Act
bool result = await strategy.CompactAsync(groups);
// Assert
Assert.True(result);
List<ChatMessage> included = [.. groups.GetIncludedMessages()];
// Q1 + collapsed tool summary + Q2
Assert.Equal(3, included.Count);
Assert.Equal("Q1", included[0].Text);
Assert.Contains("[Tool calls: get_weather]", included[1].Text);
Assert.Equal("Q2", included[2].Text);
}
[Fact]
public async Task CompactAsyncPreservesRecentToolGroupsAsync()
{
// Arrange — protect 2 recent non-system groups (the tool group + Q2)
ToolResultCompactionStrategy strategy = new(
trigger: _ => true,
minimumPreserved: 3);
MessageIndex groups = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", "search")]),
new ChatMessage(ChatRole.Tool, "Results"),
new ChatMessage(ChatRole.User, "Q2"),
]);
// Act
bool result = await strategy.CompactAsync(groups);
// Assert — all groups are in the protected window, nothing to collapse
Assert.False(result);
}
[Fact]
public async Task CompactAsyncPreservesSystemMessagesAsync()
{
// Arrange
ToolResultCompactionStrategy strategy = new(
trigger: _ => true,
minimumPreserved: 1);
MessageIndex groups = MessageIndex.Create(
[
new ChatMessage(ChatRole.System, "You are helpful."),
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", "fn")]),
new ChatMessage(ChatRole.Tool, "result"),
new ChatMessage(ChatRole.User, "Q2"),
]);
// Act
await strategy.CompactAsync(groups);
// Assert
List<ChatMessage> included = [.. groups.GetIncludedMessages()];
Assert.Equal("You are helpful.", included[0].Text);
}
[Fact]
public async Task CompactAsyncExtractsMultipleToolNamesAsync()
{
// Arrange — assistant calls two tools
ToolResultCompactionStrategy strategy = new(
trigger: _ => true,
minimumPreserved: 1);
ChatMessage multiToolCall = new(ChatRole.Assistant,
[
new FunctionCallContent("c1", "get_weather"),
new FunctionCallContent("c2", "search_docs"),
]);
MessageIndex groups = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
multiToolCall,
new ChatMessage(ChatRole.Tool, "Sunny"),
new ChatMessage(ChatRole.Tool, "Found 3 docs"),
new ChatMessage(ChatRole.User, "Q2"),
]);
// Act
await strategy.CompactAsync(groups);
// Assert
List<ChatMessage> included = [.. groups.GetIncludedMessages()];
string collapsed = included[1].Text!;
Assert.Contains("get_weather", collapsed);
Assert.Contains("search_docs", collapsed);
}
[Fact]
public async Task CompactAsyncNoToolGroupsReturnsFalseAsync()
{
// Arrange — trigger fires but no tool groups to collapse
ToolResultCompactionStrategy strategy = new(
trigger: _ => true,
minimumPreserved: 0);
MessageIndex groups = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Hello"),
new ChatMessage(ChatRole.Assistant, "Hi!"),
]);
// Act
bool result = await strategy.CompactAsync(groups);
// Assert
Assert.False(result);
}
[Fact]
public async Task CompactAsyncCompoundTriggerRequiresTokensAndToolCallsAsync()
{
// Arrange — compound: tokens > 0 AND has tool calls
ToolResultCompactionStrategy strategy = new(
CompactionTriggers.All(
CompactionTriggers.TokensExceed(0),
CompactionTriggers.HasToolCalls()),
minimumPreserved: 1);
MessageIndex groups = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "fn")]),
new ChatMessage(ChatRole.Tool, "result"),
new ChatMessage(ChatRole.User, "Q2"),
]);
// Act
bool result = await strategy.CompactAsync(groups);
// Assert
Assert.True(result);
}
[Fact]
public async Task CompactAsyncTargetStopsCollapsingEarlyAsync()
{
// Arrange — 2 tool groups, target met after first collapse
int collapseCount = 0;
CompactionTrigger targetAfterOne = _ => ++collapseCount >= 1;
ToolResultCompactionStrategy strategy = new(
trigger: _ => true,
minimumPreserved: 1,
target: targetAfterOne);
MessageIndex index = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "fn1")]),
new ChatMessage(ChatRole.Tool, "result1"),
new ChatMessage(ChatRole.User, "Q2"),
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c2", "fn2")]),
new ChatMessage(ChatRole.Tool, "result2"),
new ChatMessage(ChatRole.User, "Q3"),
]);
// Act
bool result = await strategy.CompactAsync(index);
// Assert — only first tool group collapsed, second left intact
Assert.True(result);
// Count collapsed tool groups (excluded with ToolCall kind)
int collapsedToolGroups = 0;
foreach (MessageGroup group in index.Groups)
{
if (group.IsExcluded && group.Kind == MessageGroupKind.ToolCall)
{
collapsedToolGroups++;
}
}
Assert.Equal(1, collapsedToolGroups);
}
[Fact]
public async Task CompactAsyncSkipsPreExcludedAndSystemGroupsAsync()
{
// Arrange — pre-excluded and system groups in the enumeration
ToolResultCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreserved: 0);
List<ChatMessage> messages =
[
new ChatMessage(ChatRole.System, "System prompt"),
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "fn")]),
new ChatMessage(ChatRole.Tool, "Result 1"),
new ChatMessage(ChatRole.User, "Q1"),
];
MessageIndex index = MessageIndex.Create(messages);
// Pre-exclude the user group
index.Groups[index.Groups.Count - 1].IsExcluded = true;
// Act
bool result = await strategy.CompactAsync(index);
// Assert — system never excluded, pre-excluded skipped
Assert.True(result);
Assert.False(index.Groups[0].IsExcluded); // System stays
}
}
@@ -0,0 +1,328 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Compaction;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.UnitTests.Compaction;
/// <summary>
/// Contains tests for the <see cref="TruncationCompactionStrategy"/> class.
/// </summary>
public class TruncationCompactionStrategyTests
{
[Fact]
public async Task CompactAsyncAlwaysTriggerCompactsToPreserveRecentAsync()
{
// Arrange — always-trigger means always compact
TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreserved: 1);
MessageIndex groups = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "First"),
new ChatMessage(ChatRole.Assistant, "Response 1"),
new ChatMessage(ChatRole.User, "Second"),
]);
// Act
bool result = await strategy.CompactAsync(groups);
// Assert
Assert.True(result);
Assert.Equal(1, groups.Groups.Count(g => !g.IsExcluded));
}
[Fact]
public async Task CompactAsyncTriggerNotMetReturnsFalseAsync()
{
// Arrange — trigger requires > 1000 tokens, conversation is tiny
TruncationCompactionStrategy strategy = new(
minimumPreserved: 1,
trigger: CompactionTriggers.TokensExceed(1000));
MessageIndex groups = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Hello"),
new ChatMessage(ChatRole.Assistant, "Hi!"),
]);
// Act
bool result = await strategy.CompactAsync(groups);
// Assert
Assert.False(result);
Assert.Equal(2, groups.IncludedGroupCount);
}
[Fact]
public async Task CompactAsyncTriggerMetExcludesOldestGroupsAsync()
{
// Arrange — trigger on groups > 2
TruncationCompactionStrategy strategy = new(
minimumPreserved: 1,
trigger: CompactionTriggers.GroupsExceed(2));
MessageIndex groups = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "First"),
new ChatMessage(ChatRole.Assistant, "Response 1"),
new ChatMessage(ChatRole.User, "Second"),
new ChatMessage(ChatRole.Assistant, "Response 2"),
]);
// Act
bool result = await strategy.CompactAsync(groups);
// Assert — incremental: excludes until GroupsExceed(2) is no longer met → 2 groups remain
Assert.True(result);
Assert.Equal(2, groups.IncludedGroupCount);
// Oldest 2 excluded, newest 2 kept
Assert.True(groups.Groups[0].IsExcluded);
Assert.True(groups.Groups[1].IsExcluded);
Assert.False(groups.Groups[2].IsExcluded);
Assert.False(groups.Groups[3].IsExcluded);
}
[Fact]
public async Task CompactAsyncPreservesSystemMessagesAsync()
{
// Arrange
TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreserved: 1);
MessageIndex groups = MessageIndex.Create(
[
new ChatMessage(ChatRole.System, "You are helpful."),
new ChatMessage(ChatRole.User, "First"),
new ChatMessage(ChatRole.Assistant, "Response 1"),
new ChatMessage(ChatRole.User, "Second"),
]);
// Act
bool result = await strategy.CompactAsync(groups);
// Assert
Assert.True(result);
// System message should be preserved
Assert.False(groups.Groups[0].IsExcluded);
Assert.Equal(MessageGroupKind.System, groups.Groups[0].Kind);
// Oldest non-system groups excluded
Assert.True(groups.Groups[1].IsExcluded);
Assert.True(groups.Groups[2].IsExcluded);
// Most recent kept
Assert.False(groups.Groups[3].IsExcluded);
}
[Fact]
public async Task CompactAsyncPreservesToolCallGroupAtomicityAsync()
{
// Arrange
TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreserved: 1);
ChatMessage assistantToolCall = new(ChatRole.Assistant, [new FunctionCallContent("call1", "get_weather")]);
ChatMessage toolResult = new(ChatRole.Tool, "Sunny");
ChatMessage finalResponse = new(ChatRole.User, "Thanks!");
MessageIndex groups = MessageIndex.Create([assistantToolCall, toolResult, finalResponse]);
// Act
bool result = await strategy.CompactAsync(groups);
// Assert
Assert.True(result);
// Tool call group should be excluded as one atomic unit
Assert.True(groups.Groups[0].IsExcluded);
Assert.Equal(MessageGroupKind.ToolCall, groups.Groups[0].Kind);
Assert.Equal(2, groups.Groups[0].Messages.Count);
Assert.False(groups.Groups[1].IsExcluded);
}
[Fact]
public async Task CompactAsyncSetsExcludeReasonAsync()
{
// Arrange
TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreserved: 1);
MessageIndex groups = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Old"),
new ChatMessage(ChatRole.User, "New"),
]);
// Act
await strategy.CompactAsync(groups);
// Assert
Assert.NotNull(groups.Groups[0].ExcludeReason);
Assert.Contains("TruncationCompactionStrategy", groups.Groups[0].ExcludeReason);
}
[Fact]
public async Task CompactAsyncSkipsAlreadyExcludedGroupsAsync()
{
// Arrange
TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreserved: 1);
MessageIndex groups = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Already excluded"),
new ChatMessage(ChatRole.User, "Included 1"),
new ChatMessage(ChatRole.User, "Included 2"),
]);
groups.Groups[0].IsExcluded = true;
// Act
bool result = await strategy.CompactAsync(groups);
// Assert
Assert.True(result);
Assert.True(groups.Groups[0].IsExcluded); // was already excluded
Assert.True(groups.Groups[1].IsExcluded); // newly excluded
Assert.False(groups.Groups[2].IsExcluded); // kept
}
[Fact]
public async Task CompactAsyncMinimumPreservedKeepsMultipleAsync()
{
// Arrange — keep 2 most recent
TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreserved: 2);
MessageIndex groups = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
new ChatMessage(ChatRole.Assistant, "A2"),
]);
// Act
bool result = await strategy.CompactAsync(groups);
// Assert
Assert.True(result);
Assert.True(groups.Groups[0].IsExcluded);
Assert.True(groups.Groups[1].IsExcluded);
Assert.False(groups.Groups[2].IsExcluded);
Assert.False(groups.Groups[3].IsExcluded);
}
[Fact]
public async Task CompactAsyncNothingToRemoveReturnsFalseAsync()
{
// Arrange — preserve 5 but only 2 groups
TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreserved: 5);
MessageIndex groups = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Hello"),
new ChatMessage(ChatRole.Assistant, "Hi!"),
]);
// Act
bool result = await strategy.CompactAsync(groups);
// Assert
Assert.False(result);
}
[Fact]
public async Task CompactAsyncCustomTargetStopsEarlyAsync()
{
// Arrange — always trigger, custom target stops after 1 exclusion
int targetChecks = 0;
bool TargetAfterOne(MessageIndex _) => ++targetChecks >= 1;
TruncationCompactionStrategy strategy = new(
CompactionTriggers.Always,
minimumPreserved: 1,
target: TargetAfterOne);
MessageIndex groups = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
new ChatMessage(ChatRole.User, "Q3"),
]);
// Act
bool result = await strategy.CompactAsync(groups);
// Assert — only 1 group excluded (target met after first)
Assert.True(result);
Assert.True(groups.Groups[0].IsExcluded);
Assert.False(groups.Groups[1].IsExcluded);
Assert.False(groups.Groups[2].IsExcluded);
Assert.False(groups.Groups[3].IsExcluded);
}
[Fact]
public async Task CompactAsyncIncrementalStopsAtTargetAsync()
{
// Arrange — trigger on groups > 2, target is default (inverse of trigger: groups <= 2)
TruncationCompactionStrategy strategy = new(
CompactionTriggers.GroupsExceed(2),
minimumPreserved: 1);
MessageIndex groups = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
new ChatMessage(ChatRole.Assistant, "A2"),
new ChatMessage(ChatRole.User, "Q3"),
]);
// Act — 5 groups, trigger fires (5 > 2), compacts until groups <= 2
bool result = await strategy.CompactAsync(groups);
// Assert — should stop at 2 included groups (not go all the way to 1)
Assert.True(result);
Assert.Equal(2, groups.IncludedGroupCount);
}
[Fact]
public async Task CompactAsyncLoopExitsWhenMaxRemovableReachedAsync()
{
// Arrange — target never stops (always false), so the loop must exit via removed >= maxRemovable
TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreserved: 2, target: CompactionTriggers.Never);
MessageIndex groups = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
new ChatMessage(ChatRole.Assistant, "A2"),
]);
// Act
bool result = await strategy.CompactAsync(groups);
// Assert — only 2 removed (maxRemovable = 4 - 2 = 2), 2 preserved
Assert.True(result);
Assert.Equal(2, groups.IncludedGroupCount);
Assert.True(groups.Groups[0].IsExcluded);
Assert.True(groups.Groups[1].IsExcluded);
Assert.False(groups.Groups[2].IsExcluded);
Assert.False(groups.Groups[3].IsExcluded);
}
[Fact]
public async Task CompactAsyncSkipsPreExcludedAndSystemGroupsAsync()
{
// Arrange — has excluded + system groups that the loop must skip
TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreserved: 1);
MessageIndex groups = MessageIndex.Create(
[
new ChatMessage(ChatRole.System, "System"),
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
]);
// Pre-exclude one group
groups.Groups[1].IsExcluded = true;
// Act
bool result = await strategy.CompactAsync(groups);
// Assert — system preserved, pre-excluded skipped, A1 removed, Q2 preserved
Assert.True(result);
Assert.False(groups.Groups[0].IsExcluded); // System
Assert.True(groups.Groups[1].IsExcluded); // Pre-excluded Q1
Assert.True(groups.Groups[2].IsExcluded); // Newly excluded A1
Assert.False(groups.Groups[3].IsExcluded); // Preserved Q2
}
}
@@ -16,6 +16,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="Microsoft.Extensions.Logging" />
<PackageReference Include="Microsoft.ML.Tokenizers" />
<PackageReference Include="OpenTelemetry" />
<PackageReference Include="OpenTelemetry.Exporter.InMemory" />
<PackageReference Include="System.Linq.AsyncEnumerable" />