Checkpoint

This commit is contained in:
Chris Rickman
2026-03-02 22:00:21 -08:00
parent 42d424587d
commit d6c4dbea96
30 changed files with 740 additions and 525 deletions
@@ -0,0 +1,102 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Compaction;
public partial class ChatHistoryCompactionPipeline
{
/// <summary>
/// %%% COMMENT
/// </summary>
public enum Size
{
/// <summary>
/// %%% COMMENT
/// </summary>
Compact,
/// <summary>
/// %%% COMMENT
/// </summary>
Adequate,
/// <summary>
/// %%% COMMENT
/// </summary>
Accomodating,
}
/// <summary>
/// %%% COMMENT
/// </summary>
public enum Approach
{
/// <summary>
/// %%% COMMENT
/// </summary>
Aggressive,
/// <summary>
/// %%% COMMENT
/// </summary>
Balanced,
/// <summary>
/// %%% COMMENT
/// </summary>
Gentle,
}
/// <summary>
/// %%% COMMENT
/// </summary>
/// <param name="approach"></param>
/// <param name="size"></param>
/// <param name="chatClient"></param>
/// <returns></returns>
/// <exception cref="NotImplementedException"></exception>
public static ChatHistoryCompactionPipeline Create(Approach approach, Size size, IChatClient chatClient) =>
approach switch
{
Approach.Aggressive => CreateAgressive(size, chatClient),
Approach.Balanced => CreateBalanced(size),
Approach.Gentle => CreateGentle(size),
_ => throw new NotImplementedException(), // %%% EXCEPTION
};
private static ChatHistoryCompactionPipeline CreateAgressive(Size size, IChatClient chatClient) =>
new(// 1. Gentle: collapse old tool-call groups into short summaries like "[Tool calls: LookupPrice]"
new ToolResultCompactionStrategy(MaxTokens(size), preserveRecentGroups: 2),
// 2. Moderate: use an LLM to summarize older conversation spans into a concise message
new SummarizationCompactionStrategy(chatClient, MaxTokens(size), preserveRecentGroups: 2),
// 3. Aggressive: keep only the last N user turns and their responses
new SlidingWindowCompactionStrategy(MaxTurns(size)),
// 4. Emergency: drop oldest groups until under the token budget
new TruncationCompactionStrategy(MaxTokens(size), preserveRecentGroups: 1));
private static ChatHistoryCompactionPipeline CreateBalanced(Size size) =>
new(// 1. Gentle: collapse old tool-call groups into short summaries like "[Tool calls: LookupPrice]"
new ToolResultCompactionStrategy(MaxTokens(size), preserveRecentGroups: 2),
// 2. Aggressive: keep only the last N user turns and their responses
new SlidingWindowCompactionStrategy(MaxTurns(size)));
private static ChatHistoryCompactionPipeline CreateGentle(Size size) =>
new(// 1. Gentle: collapse old tool-call groups into short summaries like "[Tool calls: LookupPrice]"
new ToolResultCompactionStrategy(MaxTokens(size), preserveRecentGroups: 2));
private static int MaxTokens(Size size) =>
size switch
{
Size.Compact => 500,
Size.Adequate => 1000,
Size.Accomodating => 2000,
_ => throw new NotImplementedException(), // %%% EXCEPTION
};
private static int MaxTurns(Size size) =>
size switch
{
Size.Compact => 10,
Size.Adequate => 50,
Size.Accomodating => 100,
_ => throw new NotImplementedException(), // %%% EXCEPTION
};
}
@@ -1,7 +1,8 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
@@ -24,7 +25,7 @@ namespace Microsoft.Agents.AI.Compaction;
/// accepted (e.g., <see cref="InMemoryChatHistoryProviderOptions.ChatReducer"/>).
/// </para>
/// </remarks>
public class ChatHistoryCompactionPipeline : IChatReducer
public partial class ChatHistoryCompactionPipeline : IChatReducer
{
private readonly ChatHistoryCompactionStrategy[] _strategies;
private readonly IChatHistoryMetricsCalculator _metricsCalculator;
@@ -52,7 +53,7 @@ public class ChatHistoryCompactionPipeline : IChatReducer
IChatHistoryMetricsCalculator? metricsCalculator,
params IEnumerable<ChatHistoryCompactionStrategy> strategies)
{
this._strategies = Throw.IfNull(strategies).ToArray();
this._strategies = [.. Throw.IfNull(strategies)];
this._metricsCalculator = metricsCalculator ?? DefaultChatHistoryMetricsCalculator.Instance;
}
@@ -66,9 +67,9 @@ public class ChatHistoryCompactionPipeline : IChatReducer
IEnumerable<ChatMessage> messages,
CancellationToken cancellationToken = default)
{
List<ChatMessage> messageList = messages.ToList(); // %%% HAXX
await this.CompactAsync(messageList, cancellationToken).ConfigureAwait(false);
return messageList;
List<ChatMessage> messageBuffer = messages is List<ChatMessage> messageList ? messageList : [.. messages];
await this.CompactAsync(messageBuffer, cancellationToken).ConfigureAwait(false);
return messageBuffer;
}
/// <summary>
@@ -77,26 +78,37 @@ public class ChatHistoryCompactionPipeline : IChatReducer
/// <param name="messages">The mutable message list to compact.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
/// <returns>A <see cref="CompactionPipelineResult"/> with aggregate and per-strategy metrics.</returns>
public async ValueTask<CompactionPipelineResult> CompactAsync( // %%% SCOPE
IList<ChatMessage> messages,
public async ValueTask<CompactionPipelineResult> CompactAsync(
List<ChatMessage> messages,
CancellationToken cancellationToken = default)
{
Throw.IfNull(messages);
IReadOnlyList<ChatMessage> readOnlyMessages = messages as IReadOnlyList<ChatMessage> ?? [.. messages]; // %%% TYPE CONSISTENCY
CompactionMetric overallBefore = this._metricsCalculator.Calculate(readOnlyMessages);
ChatHistoryMetric overallBefore = this._metricsCalculator.Calculate(messages);
List<CompactionResult> results = new(this._strategies.Length);
Debug.WriteLine($"COMPACTION: BEGIN x{overallBefore.MessageCount}/#{overallBefore.UserTurnCount} ({overallBefore.TokenCount} tokens)");
List<CompactionResult> compactionResults = new(this._strategies.Length);
Stopwatch timer = new();
TimeSpan startTime = TimeSpan.Zero;
ChatHistoryMetric overallAfter = overallBefore;
ChatHistoryMetric currentBefore = overallBefore;
foreach (ChatHistoryCompactionStrategy strategy in this._strategies)
{
CompactionResult result = await strategy.CompactAsync(messages, this._metricsCalculator, cancellationToken).ConfigureAwait(false);
results.Add(result);
// %%% VERBOSE - Debug.WriteLine($"COMPACTION: {strategy.Name} START");
timer.Start();
ChatHistoryCompactionStrategy.s_currentMetrics.Value = currentBefore;
CompactionResult strategyResult = await strategy.CompactAsync(messages, this._metricsCalculator, cancellationToken).ConfigureAwait(false);
timer.Stop();
TimeSpan elapsedTime = timer.Elapsed - startTime;
// %%% VERBOSE - Debug.WriteLine($"COMPACTION: {strategy.Name} FINISH [{elapsedTime}]");
compactionResults.Add(strategyResult);
overallAfter = currentBefore = strategyResult.After;
}
readOnlyMessages = messages as IReadOnlyList<ChatMessage> ?? [.. messages];
CompactionMetric overallAfter = this._metricsCalculator.Calculate(readOnlyMessages);
Debug.WriteLineIf(overallBefore.TokenCount != overallAfter.TokenCount, $"COMPACTION: TOTAL [{timer.Elapsed}] {overallBefore.TokenCount} => {overallAfter.TokenCount} tokens");
return new(overallBefore, overallAfter, results);
return new(overallBefore, overallAfter, compactionResults);
}
}
@@ -2,8 +2,7 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
@@ -21,7 +20,7 @@ namespace Microsoft.Agents.AI.Compaction;
/// while the strategy adds:
/// <list type="bullet">
/// <item><description>A conditional trigger via <see cref="ShouldCompact"/> that decides whether compaction runs.</description></item>
/// <item><description>Before/after <see cref="CompactionMetric"/> reporting via <see cref="CompactionResult"/>.</description></item>
/// <item><description>Before/after <see cref="ChatHistoryMetric"/> reporting via <see cref="CompactionResult"/>.</description></item>
/// </list>
/// </para>
/// <para>
@@ -36,7 +35,7 @@ namespace Microsoft.Agents.AI.Compaction;
/// </remarks>
public abstract class ChatHistoryCompactionStrategy
{
private static readonly AsyncLocal<CompactionMetric> s_currentMetrics = new();
internal static readonly AsyncLocal<ChatHistoryMetric> s_currentMetrics = new();
/// <summary>
/// Initializes a new instance of the <see cref="ChatHistoryCompactionStrategy"/> class.
@@ -48,9 +47,9 @@ public abstract class ChatHistoryCompactionStrategy
}
/// <summary>
/// Exposes the current <see cref="CompactionMetric"/> for the executing strategy, allowing <see cref="Reducer"/> to make informed decisions.
/// Exposes the current <see cref="ChatHistoryMetric"/> for the executing strategy, allowing <see cref="Reducer"/> to make informed decisions.
/// </summary>
protected static CompactionMetric CurrentMetrics => s_currentMetrics.Value ?? throw new InvalidOperationException($"No active {nameof(ChatHistoryCompactionStrategy)}.");
protected static ChatHistoryMetric CurrentMetrics => s_currentMetrics.Value ?? throw new InvalidOperationException($"No active {nameof(ChatHistoryCompactionStrategy)}.");
/// <summary>
/// Gets the <see cref="IChatReducer"/> that performs the actual message compaction.
@@ -72,48 +71,50 @@ public abstract class ChatHistoryCompactionStrategy
/// <returns>
/// <see langword="true"/> to proceed with compaction; <see langword="false"/> to skip.
/// </returns>
public abstract bool ShouldCompact(CompactionMetric metrics);
protected abstract bool ShouldCompact(ChatHistoryMetric metrics);
/// <summary>
/// Execute this strategy: check the trigger, delegate to the <see cref="IChatReducer"/>, and report metrics.
/// </summary>
/// <param name="messages">The mutable message list to compact.</param>
/// <param name="history">The mutable message list to compact.</param>
/// <param name="metricsCalculator">The calculator to use for metric snapshots.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
/// <returns>A <see cref="CompactionResult"/> reporting the outcome.</returns>
public async ValueTask<CompactionResult> CompactAsync(
IList<ChatMessage> messages,
internal async ValueTask<CompactionResult> CompactAsync(
List<ChatMessage> history,
IChatHistoryMetricsCalculator metricsCalculator,
CancellationToken cancellationToken = default)
{
messages = Throw.IfNull(messages);
Throw.IfNull(metricsCalculator);
Throw.IfNull(history);
List<ChatMessage>? messageList = messages as List<ChatMessage>;
ReadOnlyCollection<ChatMessage> snapshot = messageList is not null ? messageList.AsReadOnly() : new(messages);
CompactionMetric before = metricsCalculator.Calculate(snapshot);
s_currentMetrics.Value = before;
if (!this.ShouldCompact(before))
ChatHistoryMetric beforeMetrics = CurrentMetrics;
if (!this.ShouldCompact(beforeMetrics))
{
return CompactionResult.Skipped(this.Name, before);
// %%% VERBOSE - Debug.WriteLine($"COMPACTION: {this.Name} - Skipped");
return CompactionResult.Skipped(this.Name, beforeMetrics);
}
ChatMessage[] reduced = (await this.Reducer.ReduceAsync(snapshot, cancellationToken).ConfigureAwait(false)).ToArray();
Debug.WriteLine($"COMPACTION: {this.Name} - Reducing");
bool modified = reduced.Length != snapshot.Count;
IEnumerable<ChatMessage> reducerResult = await this.Reducer.ReduceAsync(history, cancellationToken).ConfigureAwait(false);
// Ensure we have a concrete collection to avoid multiple enumerations of the reducer result, which could be costly if it's an iterator.
ChatMessage[] reducedCopy = [.. reducerResult];
bool modified = reducedCopy.Length != history.Count;
if (modified)
{
messages.Clear();
foreach (ChatMessage message in reduced)
{
messages.Add(message);
}
history.Clear();
history.AddRange(reducedCopy);
}
CompactionMetric after = modified
? metricsCalculator.Calculate(reduced)
: before;
ChatHistoryMetric afterMetrics = modified
? metricsCalculator.Calculate(reducedCopy)
: beforeMetrics;
return new(this.Name, applied: modified, before, after);
Debug.WriteLine($"COMPACTION: {this.Name} - Tokens {beforeMetrics.TokenCount} => {afterMetrics.TokenCount}");
return new(this.Name, applied: modified, beforeMetrics, afterMetrics);
}
}
@@ -7,7 +7,7 @@ namespace Microsoft.Agents.AI.Compaction;
/// <summary>
/// Immutable snapshot of conversation metrics used for compaction trigger evaluation and reporting.
/// </summary>
public sealed class CompactionMetric
public sealed class ChatHistoryMetric
{
/// <summary>
/// Gets the estimated token count across all messages.
@@ -0,0 +1,35 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using Microsoft.Extensions.AI;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Compaction;
/// <summary>
/// Represents a chat history compaction strategy that uses a condition function to determine when compaction should
/// occur.
/// </summary>
/// <remarks>
/// This strategy evaluates a user-provided condition against compaction metrics to decide whether to
/// compact the chat history. It is useful for scenarios where compaction should be triggered based on custom thresholds
/// or criteria. Inherits from ChatHistoryCompactionStrategy.
/// </remarks>
public class ChatReducerCompactionStrategy : ChatHistoryCompactionStrategy
{
private readonly Func<ChatHistoryMetric, bool> _condition;
/// <summary>
/// Initializes a new instance of the <see cref="ChatReducerCompactionStrategy"/> class.
/// </summary>
public ChatReducerCompactionStrategy(
IChatReducer reducer,
Func<ChatHistoryMetric, bool> condition)
: base(reducer)
{
this._condition = Throw.IfNull(condition);
}
/// <inheritdoc/>
protected override bool ShouldCompact(ChatHistoryMetric metrics) => this._condition(metrics);
}
@@ -18,8 +18,8 @@ public sealed class CompactionPipelineResult
/// <param name="after">Metrics of the conversation after all strategies ran.</param>
/// <param name="strategyResults">Per-strategy results in execution order.</param>
internal CompactionPipelineResult(
CompactionMetric before,
CompactionMetric after,
ChatHistoryMetric before,
ChatHistoryMetric after,
IReadOnlyList<CompactionResult> strategyResults)
{
this.Before = Throw.IfNull(before);
@@ -30,12 +30,12 @@ public sealed class CompactionPipelineResult
/// <summary>
/// Gets the conversation metrics before any compaction strategy ran.
/// </summary>
public CompactionMetric Before { get; }
public ChatHistoryMetric Before { get; }
/// <summary>
/// Gets the conversation metrics after all compaction strategies ran.
/// </summary>
public CompactionMetric After { get; }
public ChatHistoryMetric After { get; }
/// <summary>
/// Gets the per-strategy results in execution order.
@@ -16,7 +16,7 @@ public sealed class CompactionResult
/// <param name="applied">Whether the strategy modified the message list.</param>
/// <param name="before">Metrics before the strategy ran.</param>
/// <param name="after">Metrics after the strategy ran.</param>
public CompactionResult(string strategyName, bool applied, CompactionMetric before, CompactionMetric after)
public CompactionResult(string strategyName, bool applied, ChatHistoryMetric before, ChatHistoryMetric after)
{
this.StrategyName = Throw.IfNullOrWhitespace(strategyName);
this.Applied = applied;
@@ -37,12 +37,12 @@ public sealed class CompactionResult
/// <summary>
/// Gets the conversation metrics before the strategy executed.
/// </summary>
public CompactionMetric Before { get; }
public ChatHistoryMetric Before { get; }
/// <summary>
/// Gets the conversation metrics after the strategy executed.
/// </summary>
public CompactionMetric After { get; }
public ChatHistoryMetric After { get; }
/// <summary>
/// Creates a <see cref="CompactionResult"/> representing a skipped strategy.
@@ -50,6 +50,6 @@ public sealed class CompactionResult
/// <param name="strategyName">The name of the skipped strategy.</param>
/// <param name="metrics">The current conversation metrics.</param>
/// <returns>A result indicating no compaction was applied.</returns>
internal static CompactionResult Skipped(string strategyName, CompactionMetric metrics)
internal static CompactionResult Skipped(string strategyName, ChatHistoryMetric metrics)
=> new(strategyName, applied: false, metrics, metrics);
}
@@ -46,7 +46,7 @@ public sealed class DefaultChatHistoryMetricsCalculator : IChatHistoryMetricsCal
}
/// <inheritdoc/>
public CompactionMetric Calculate(IReadOnlyList<ChatMessage> messages)
public ChatHistoryMetric Calculate(IReadOnlyList<ChatMessage> messages)
{
if (messages is null || messages.Count == 0)
{
@@ -9,18 +9,18 @@ namespace Microsoft.Agents.AI.Compaction;
// and whether custom metrics calculators are a realistic extension point.
/// <summary>
/// Computes <see cref="CompactionMetric"/> for a list of messages.
/// Computes <see cref="ChatHistoryMetric"/> for a list of messages.
/// </summary>
/// <remarks>
/// Token counting is model-specific. Implementations can provide precise tokenization
/// (e.g., using tiktoken or a model-specific tokenizer) or use estimation heuristics.
/// </remarks>
public interface IChatHistoryMetricsCalculator // %%% NEEDED ???
public interface IChatHistoryMetricsCalculator
{
/// <summary>
/// Compute metrics for the given messages.
/// </summary>
/// <param name="messages">The messages to analyze.</param>
/// <returns>A <see cref="CompactionMetric"/> snapshot.</returns>
CompactionMetric Calculate(IReadOnlyList<ChatMessage> messages);
/// <returns>A <see cref="ChatHistoryMetric"/> snapshot.</returns>
ChatHistoryMetric Calculate(IReadOnlyList<ChatMessage> messages);
}
@@ -1,6 +1,5 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
@@ -44,7 +43,7 @@ public class SlidingWindowCompactionStrategy : ChatHistoryCompactionStrategy
}
/// <inheritdoc/>
public override bool ShouldCompact(CompactionMetric metrics) =>
protected override bool ShouldCompact(ChatHistoryMetric metrics) =>
metrics.UserTurnCount > this._maxTurns;
/// <summary>
@@ -57,25 +56,21 @@ public class SlidingWindowCompactionStrategy : ChatHistoryCompactionStrategy
IEnumerable<ChatMessage> messages,
CancellationToken cancellationToken = default)
{
IReadOnlyList<ChatMessage> messageList = [.. messages];
IReadOnlyList<ChatMessage> messageList = [.. messages]; // %%% PERFORMANCE
IReadOnlyList<ChatMessageGroup> groups = CurrentMetrics.Groups;
// Find the group-list indices where each user turn starts
//int[] turnGroupIndices = groups.Where(group => group.Kind == ChatMessageGroupKind.UserTurn).Select(group => group.StartIndex).ToArray(); // %%% TODO
List<int> turnGroupIndices = [];
for (int i = 0; i < groups.Count; i++)
{
if (groups[i].Kind == ChatMessageGroupKind.UserTurn)
{
turnGroupIndices.Add(i);
}
}
int[] turnGroupIndices =
[.. CurrentMetrics.Groups
.Select((group, index) => (group, index))
.Where(t => t.group.Kind == ChatMessageGroupKind.UserTurn)
.Select(t => t.index)];
// Keep the last maxTurns user turns and everything after the first kept turn
int firstKeptTurnIndex = turnGroupIndices.Count - maxTurns;
int firstKeptTurnIndex = turnGroupIndices.Length - maxTurns;
int firstKeptGroupIndex = turnGroupIndices[firstKeptTurnIndex];
List<ChatMessage> result = new(messageList.Count);
List<ChatMessage> result = new(messageList.Count); // %%% PERFORMANCE
for (int gi = 0; gi < groups.Count; gi++)
{
ChatMessageGroup group = groups[gi];
@@ -69,7 +69,7 @@ public class SummarizationCompactionStrategy : ChatHistoryCompactionStrategy
}
/// <inheritdoc/>
public override bool ShouldCompact(CompactionMetric metrics) =>
protected override bool ShouldCompact(ChatHistoryMetric metrics) =>
metrics.TokenCount > this._maxTokens;
/// <summary>
@@ -96,7 +96,7 @@ public class SummarizationCompactionStrategy : ChatHistoryCompactionStrategy
IReadOnlyList<ChatMessage> messageList = [.. messages];
IReadOnlyList<ChatMessageGroup> groups = CurrentMetrics.Groups;
List<ChatMessageGroup> nonSystemGroups = groups.Where(g => g.Kind != ChatMessageGroupKind.System).ToList();
List<ChatMessageGroup> nonSystemGroups = [.. groups.Where(g => g.Kind != ChatMessageGroupKind.System)];
int protectedFromIndex = Math.Max(0, nonSystemGroups.Count - this._preserveRecentGroups);
if (protectedFromIndex == 0)
@@ -29,6 +29,11 @@ namespace Microsoft.Agents.AI.Compaction;
/// </remarks>
public class ToolResultCompactionStrategy : ChatHistoryCompactionStrategy
{
/// <summary>
/// The default value for `preserveRecentGroups` used when constructing <see cref="ToolResultCompactionStrategy"/>.
/// </summary>
public const int DefaultPreserveRecentGroups = 2;
private readonly int _maxTokens;
/// <summary>
@@ -39,14 +44,14 @@ public class ToolResultCompactionStrategy : ChatHistoryCompactionStrategy
/// The number of most-recent non-system message groups to protect from collapsing.
/// Defaults to 2, ensuring the current turn's tool interactions remain visible.
/// </param>
public ToolResultCompactionStrategy(int maxTokens, int preserveRecentGroups = 2)
public ToolResultCompactionStrategy(int maxTokens, int preserveRecentGroups = DefaultPreserveRecentGroups)
: base(new ToolResultClearingReducer(preserveRecentGroups))
{
this._maxTokens = maxTokens;
}
/// <inheritdoc/>
public override bool ShouldCompact(CompactionMetric metrics) =>
protected override bool ShouldCompact(ChatHistoryMetric metrics) =>
metrics.TokenCount > this._maxTokens && metrics.ToolCallCount > 0;
/// <summary>
@@ -62,7 +67,7 @@ public class ToolResultCompactionStrategy : ChatHistoryCompactionStrategy
IReadOnlyList<ChatMessage> messageList = [.. messages];
IReadOnlyList<ChatMessageGroup> groups = CurrentMetrics.Groups;
List<ChatMessageGroup> nonSystemGroups = groups.Where(g => g.Kind != ChatMessageGroupKind.System).ToList();
List<ChatMessageGroup> nonSystemGroups = [.. groups.Where(g => g.Kind != ChatMessageGroupKind.System)];
int protectedFromIndex = Math.Max(0, nonSystemGroups.Count - preserveRecentGroups);
HashSet<int> protectedGroupStarts = [];
for (int i = protectedFromIndex; i < nonSystemGroups.Count; i++)
@@ -41,7 +41,7 @@ public class TruncationCompactionStrategy : ChatHistoryCompactionStrategy
}
/// <inheritdoc/>
public override bool ShouldCompact(CompactionMetric metrics) =>
protected override bool ShouldCompact(ChatHistoryMetric metrics) =>
metrics.TokenCount > this._maxTokens;
/// <summary>
@@ -56,15 +56,15 @@ public class TruncationCompactionStrategy : ChatHistoryCompactionStrategy
{
IReadOnlyList<ChatMessage> messageList = [.. messages];
List<ChatMessageGroup> removableGroups = CurrentMetrics.Groups.Where(g => g.Kind != ChatMessageGroupKind.System).ToList();
ChatMessageGroup[] removableGroups = [.. CurrentMetrics.Groups.Where(g => g.Kind != ChatMessageGroupKind.System)];
if (removableGroups.Count == 0)
if (removableGroups.Length == 0)
{
return Task.FromResult<IEnumerable<ChatMessage>>(messageList);
}
// Remove oldest non-system groups, keeping at least preserveRecentGroups.
int maxRemovable = removableGroups.Count - preserveRecentGroups;
int maxRemovable = removableGroups.Length - preserveRecentGroups;
if (maxRemovable <= 0)
{