mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Checkpoint
This commit is contained in:
+102
@@ -0,0 +1,102 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// Executes a chain of <see cref="ChatHistoryCompactionStrategy"/> instances in order
|
||||
/// against a mutable message list.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Each strategy's trigger is evaluated against the metrics <em>as they stand after prior strategies</em>,
|
||||
/// so earlier strategies can bring the conversation within thresholds that cause later strategies to skip.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The pipeline is fully standalone — it can be used without any agent, session, or context provider.
|
||||
/// It also implements <see cref="IChatReducer"/> so it can be used directly anywhere a reducer is
|
||||
/// accepted (e.g., <see cref="InMemoryChatHistoryProviderOptions.ChatReducer"/>).
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public class ChatHistoryCompactionPipeline : IChatReducer
|
||||
{
|
||||
private readonly ChatHistoryCompactionStrategy[] _strategies;
|
||||
private readonly IChatHistoryMetricsCalculator _metricsCalculator;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ChatHistoryCompactionPipeline"/> class.
|
||||
/// </summary>
|
||||
/// <param name="strategies">The ordered list of compaction strategies to execute.</param>
|
||||
/// <remarks>
|
||||
/// By default, <see cref="DefaultChatHistoryMetricsCalculator"/> is used.
|
||||
/// </remarks>
|
||||
public ChatHistoryCompactionPipeline(
|
||||
IEnumerable<ChatHistoryCompactionStrategy> strategies)
|
||||
: this(metricsCalculator: null, strategies) { }
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ChatHistoryCompactionPipeline"/> class.
|
||||
/// </summary>
|
||||
/// <param name="metricsCalculator">
|
||||
/// An optional metrics calculator. When <see langword="null"/>, a
|
||||
/// <see cref="DefaultChatHistoryMetricsCalculator"/> is used.
|
||||
/// </param>
|
||||
/// <param name="strategies">The ordered list of compaction strategies to execute.</param>
|
||||
public ChatHistoryCompactionPipeline(
|
||||
IChatHistoryMetricsCalculator? metricsCalculator,
|
||||
params IEnumerable<ChatHistoryCompactionStrategy> strategies)
|
||||
{
|
||||
this._strategies = Throw.IfNull(strategies).ToArray();
|
||||
this._metricsCalculator = metricsCalculator ?? DefaultChatHistoryMetricsCalculator.Instance;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reduces the given messages by running all strategies in sequence.
|
||||
/// </summary>
|
||||
/// <param name="messages">The messages to reduce.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
|
||||
/// <returns>The reduced set of messages.</returns>
|
||||
public virtual async Task<IEnumerable<ChatMessage>> ReduceAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
List<ChatMessage> messageList = messages.ToList(); // %%% HAXX
|
||||
await this.CompactAsync(messageList, cancellationToken).ConfigureAwait(false);
|
||||
return messageList;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Run all strategies in sequence against the given messages.
|
||||
/// </summary>
|
||||
/// <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,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Throw.IfNull(messages);
|
||||
|
||||
IReadOnlyList<ChatMessage> readOnlyMessages = messages as IReadOnlyList<ChatMessage> ?? [.. messages]; // %%% TYPE CONSISTENCY
|
||||
CompactionMetric overallBefore = this._metricsCalculator.Calculate(readOnlyMessages);
|
||||
|
||||
List<CompactionResult> results = new(this._strategies.Length);
|
||||
|
||||
foreach (ChatHistoryCompactionStrategy strategy in this._strategies)
|
||||
{
|
||||
CompactionResult result = await strategy.CompactAsync(messages, this._metricsCalculator, cancellationToken).ConfigureAwait(false);
|
||||
results.Add(result);
|
||||
}
|
||||
|
||||
readOnlyMessages = messages as IReadOnlyList<ChatMessage> ?? [.. messages];
|
||||
CompactionMetric overallAfter = this._metricsCalculator.Calculate(readOnlyMessages);
|
||||
|
||||
return new(overallBefore, overallAfter, results);
|
||||
}
|
||||
}
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// A named compaction strategy with an optional conditional trigger that delegates
|
||||
/// actual message reduction to an <see cref="IChatReducer"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Each strategy wraps an <see cref="IChatReducer"/> that performs the actual 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>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// For simple cases, construct a <see cref="ChatHistoryCompactionStrategy"/> directly with any
|
||||
/// <see cref="IChatReducer"/>. For custom trigger logic, subclass and override <see cref="ShouldCompact"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Reducers <b>must</b> preserve atomic message groups: an assistant message containing
|
||||
/// tool calls and its corresponding tool result messages must be kept or removed together.
|
||||
/// Use <see cref="DefaultChatHistoryMetricsCalculator"/> to identify these groups when authoring custom reducers.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public abstract class ChatHistoryCompactionStrategy
|
||||
{
|
||||
private static readonly AsyncLocal<CompactionMetric> s_currentMetrics = new();
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ChatHistoryCompactionStrategy"/> class.
|
||||
/// </summary>
|
||||
/// <param name="reducer">The <see cref="IChatReducer"/> that performs the actual message compaction.</param>
|
||||
protected ChatHistoryCompactionStrategy(IChatReducer reducer)
|
||||
{
|
||||
this.Reducer = Throw.IfNull(reducer);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exposes the current <see cref="CompactionMetric"/> 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)}.");
|
||||
|
||||
/// <summary>
|
||||
/// Gets the <see cref="IChatReducer"/> that performs the actual message compaction.
|
||||
/// </summary>
|
||||
public IChatReducer Reducer { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the display name of this strategy, used for logging and diagnostics.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The default implementation returns the type name of the underlying <see cref="IChatReducer"/>.
|
||||
/// </remarks>
|
||||
public virtual string Name => this.Reducer.GetType().Name;
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates whether this strategy should execute given the current conversation metrics.
|
||||
/// </summary>
|
||||
/// <param name="metrics">The current conversation metrics.</param>
|
||||
/// <returns>
|
||||
/// <see langword="true"/> to proceed with compaction; <see langword="false"/> to skip.
|
||||
/// </returns>
|
||||
public abstract bool ShouldCompact(CompactionMetric 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="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,
|
||||
IChatHistoryMetricsCalculator metricsCalculator,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
messages = Throw.IfNull(messages);
|
||||
Throw.IfNull(metricsCalculator);
|
||||
|
||||
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))
|
||||
{
|
||||
return CompactionResult.Skipped(this.Name, before);
|
||||
}
|
||||
|
||||
ChatMessage[] reduced = (await this.Reducer.ReduceAsync(snapshot, cancellationToken).ConfigureAwait(false)).ToArray();
|
||||
|
||||
bool modified = reduced.Length != snapshot.Count;
|
||||
if (modified)
|
||||
{
|
||||
messages.Clear();
|
||||
foreach (ChatMessage message in reduced)
|
||||
{
|
||||
messages.Add(message);
|
||||
}
|
||||
}
|
||||
|
||||
CompactionMetric after = modified
|
||||
? metricsCalculator.Calculate(reduced)
|
||||
: before;
|
||||
|
||||
return new(this.Name, applied: modified, before, after);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
|
||||
namespace Microsoft.Agents.AI.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a contiguous range of messages in a conversation that form an atomic group.
|
||||
/// Atomic groups must be kept or removed together to maintain API correctness.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// For example, an assistant message containing tool calls and the subsequent tool result messages
|
||||
/// form an atomic group — removing one without the other causes API errors.
|
||||
/// </remarks>
|
||||
public readonly struct ChatMessageGroup : IEquatable<ChatMessageGroup>
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ChatMessageGroup"/> struct.
|
||||
/// </summary>
|
||||
/// <param name="startIndex">The zero-based index of the first message in this group.</param>
|
||||
/// <param name="count">The number of messages in this group.</param>
|
||||
/// <param name="kind">The kind of this message group.</param>
|
||||
public ChatMessageGroup(int startIndex, int count, ChatMessageGroupKind kind)
|
||||
{
|
||||
this.StartIndex = startIndex;
|
||||
this.Count = count;
|
||||
this.Kind = kind;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the zero-based index of the first message in this group within the original message list.
|
||||
/// </summary>
|
||||
public int StartIndex { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of messages in this group.
|
||||
/// </summary>
|
||||
public int Count { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the kind of this message group.
|
||||
/// </summary>
|
||||
public ChatMessageGroupKind Kind { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public bool Equals(ChatMessageGroup other) =>
|
||||
this.StartIndex == other.StartIndex &&
|
||||
this.Count == other.Count &&
|
||||
this.Kind == other.Kind;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override bool Equals(object? obj) =>
|
||||
obj is ChatMessageGroup other &&
|
||||
this.Equals(other);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override int GetHashCode() => HashCode.Combine(this.StartIndex, this.Count, (int)this.Kind);
|
||||
|
||||
/// <summary>Determines whether two <see cref="ChatMessageGroup"/> instances are equal.</summary>
|
||||
public static bool operator ==(ChatMessageGroup left, ChatMessageGroup right) => left.Equals(right);
|
||||
|
||||
/// <summary>Determines whether two <see cref="ChatMessageGroup"/> instances are not equal.</summary>
|
||||
public static bool operator !=(ChatMessageGroup left, ChatMessageGroup right) => !left.Equals(right);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Microsoft.Agents.AI.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// Identifies the kind of an atomic message group in a conversation.
|
||||
/// </summary>
|
||||
public enum ChatMessageGroupKind
|
||||
{
|
||||
/// <summary>A system message.</summary>
|
||||
System,
|
||||
|
||||
/// <summary>A user message (start of a user turn).</summary>
|
||||
UserTurn,
|
||||
|
||||
/// <summary>An assistant message with tool calls and their corresponding tool result messages.</summary>
|
||||
AssistantToolGroup,
|
||||
|
||||
/// <summary>An assistant message without tool calls.</summary>
|
||||
AssistantPlain,
|
||||
|
||||
/// <summary>A tool result message that is not part of a recognized group.</summary>
|
||||
ToolResult,
|
||||
|
||||
/// <summary>A message with an unrecognized role.</summary>
|
||||
Other
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Microsoft.Agents.AI.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// Immutable snapshot of conversation metrics used for compaction trigger evaluation and reporting.
|
||||
/// </summary>
|
||||
public sealed class CompactionMetric
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the estimated token count across all messages.
|
||||
/// </summary>
|
||||
public int TokenCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total serialized byte count of all messages.
|
||||
/// </summary>
|
||||
public long ByteCount { get; init; }
|
||||
|
||||
#pragma warning disable IDE0001 // Simplify Names
|
||||
/// <summary>
|
||||
/// Gets the total number of <see cref="Microsoft.Extensions.AI.ChatMessage"/> objects.
|
||||
/// </summary>
|
||||
#pragma warning restore IDE0001 // Simplify Names
|
||||
public int MessageCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of tool/function call content items across all messages.
|
||||
/// </summary>
|
||||
public int ToolCallCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of user turns. A user turn is a user message together with the full
|
||||
/// set of agent responses (including tool calls and results) before the next user input.
|
||||
/// </summary>
|
||||
public int UserTurnCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the atomic message group index for the analyzed messages.
|
||||
/// Each group represents a contiguous range of messages that must be kept or removed together.
|
||||
/// </summary>
|
||||
public IReadOnlyList<ChatMessageGroup> Groups { get; init; } = [];
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// Reports the aggregate outcome of a <see cref="ChatHistoryCompactionPipeline"/> execution.
|
||||
/// </summary>
|
||||
public sealed class CompactionPipelineResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="CompactionPipelineResult"/> class.
|
||||
/// </summary>
|
||||
/// <param name="before">Metrics of the conversation before any strategy ran.</param>
|
||||
/// <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,
|
||||
IReadOnlyList<CompactionResult> strategyResults)
|
||||
{
|
||||
this.Before = Throw.IfNull(before);
|
||||
this.After = Throw.IfNull(after);
|
||||
this.StrategyResults = Throw.IfNull(strategyResults);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the conversation metrics before any compaction strategy ran.
|
||||
/// </summary>
|
||||
public CompactionMetric Before { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the conversation metrics after all compaction strategies ran.
|
||||
/// </summary>
|
||||
public CompactionMetric After { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the per-strategy results in execution order.
|
||||
/// </summary>
|
||||
public IReadOnlyList<CompactionResult> StrategyResults { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether any strategy modified the message list.
|
||||
/// </summary>
|
||||
public bool AnyApplied => this.StrategyResults.Any(r => r.Applied);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// Reports the outcome of a single <see cref="ChatHistoryCompactionStrategy"/> execution.
|
||||
/// </summary>
|
||||
public sealed class CompactionResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="CompactionResult"/> class.
|
||||
/// </summary>
|
||||
/// <param name="strategyName">The name of the strategy that produced this result.</param>
|
||||
/// <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)
|
||||
{
|
||||
this.StrategyName = Throw.IfNullOrWhitespace(strategyName);
|
||||
this.Applied = applied;
|
||||
this.Before = Throw.IfNull(before);
|
||||
this.After = Throw.IfNull(after);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the name of the strategy that produced this result.
|
||||
/// </summary>
|
||||
public string StrategyName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the strategy modified the message list.
|
||||
/// </summary>
|
||||
public bool Applied { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the conversation metrics before the strategy executed.
|
||||
/// </summary>
|
||||
public CompactionMetric Before { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the conversation metrics after the strategy executed.
|
||||
/// </summary>
|
||||
public CompactionMetric After { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a <see cref="CompactionResult"/> representing a skipped strategy.
|
||||
/// </summary>
|
||||
/// <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)
|
||||
=> new(strategyName, applied: false, metrics, metrics);
|
||||
}
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of <see cref="IChatHistoryMetricsCalculator"/> that uses
|
||||
/// JSON serialization length heuristics for token and byte estimation.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Token estimation uses a configurable characters-per-token ratio (default ~4) since
|
||||
/// precise tokenization requires a model-specific tokenizer. For production workloads
|
||||
/// requiring accurate token counts, implement <see cref="IChatHistoryMetricsCalculator"/>
|
||||
/// with a model-appropriate tokenizer.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class DefaultChatHistoryMetricsCalculator : IChatHistoryMetricsCalculator
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the singleton instance of the chat history metrics calculator.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <see cref="DefaultChatHistoryMetricsCalculator"/> can be safety accessed by
|
||||
/// concurrent threads.
|
||||
/// </remarks>
|
||||
public static readonly DefaultChatHistoryMetricsCalculator Instance = new();
|
||||
|
||||
private const int DefaultCharsPerToken = 4;
|
||||
private const int PerMessageOverheadTokens = 4;
|
||||
|
||||
private readonly int _charsPerToken;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DefaultChatHistoryMetricsCalculator"/> class.
|
||||
/// </summary>
|
||||
/// <param name="charsPerToken">
|
||||
/// The approximate number of characters per token used for estimation. Default is 4.
|
||||
/// </param>
|
||||
public DefaultChatHistoryMetricsCalculator(int charsPerToken = DefaultCharsPerToken)
|
||||
{
|
||||
this._charsPerToken = charsPerToken > 0 ? charsPerToken : DefaultCharsPerToken;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public CompactionMetric Calculate(IReadOnlyList<ChatMessage> messages)
|
||||
{
|
||||
if (messages is null || messages.Count == 0)
|
||||
{
|
||||
return new();
|
||||
}
|
||||
|
||||
int totalTokens = 0;
|
||||
long totalBytes = 0;
|
||||
int toolCallCount = 0;
|
||||
int userTurnCount = 0;
|
||||
bool inUserTurn = false;
|
||||
List<ChatMessageGroup> groups = [];
|
||||
int index = 0;
|
||||
|
||||
while (index < messages.Count)
|
||||
{
|
||||
ChatMessage message = messages[index];
|
||||
|
||||
// Accumulate per-message metrics
|
||||
this.AccumulateMessageMetrics(message, ref totalTokens, ref totalBytes, ref toolCallCount);
|
||||
|
||||
if (message.Role == ChatRole.User)
|
||||
{
|
||||
if (!inUserTurn)
|
||||
{
|
||||
userTurnCount++;
|
||||
inUserTurn = true;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
inUserTurn = false;
|
||||
}
|
||||
|
||||
// Identify the group starting at this message
|
||||
if (message.Role == ChatRole.System)
|
||||
{
|
||||
groups.Add(new(index, 1, ChatMessageGroupKind.System));
|
||||
index++;
|
||||
}
|
||||
else if (message.Role == ChatRole.User)
|
||||
{
|
||||
groups.Add(new(index, 1, ChatMessageGroupKind.UserTurn));
|
||||
index++;
|
||||
}
|
||||
else if (message.Role == ChatRole.Assistant)
|
||||
{
|
||||
bool hasToolCalls = message.Contents!.Any(c => c is FunctionCallContent);
|
||||
|
||||
if (hasToolCalls)
|
||||
{
|
||||
int groupStart = index;
|
||||
index++;
|
||||
|
||||
while (index < messages.Count && messages[index].Role == ChatRole.Tool)
|
||||
{
|
||||
this.AccumulateMessageMetrics(messages[index], ref totalTokens, ref totalBytes, ref toolCallCount);
|
||||
inUserTurn = false;
|
||||
index++;
|
||||
}
|
||||
|
||||
groups.Add(new(groupStart, index - groupStart, ChatMessageGroupKind.AssistantToolGroup));
|
||||
}
|
||||
else
|
||||
{
|
||||
groups.Add(new(index, 1, ChatMessageGroupKind.AssistantPlain));
|
||||
index++;
|
||||
}
|
||||
}
|
||||
else if (message.Role == ChatRole.Tool)
|
||||
{
|
||||
groups.Add(new(index, 1, ChatMessageGroupKind.ToolResult));
|
||||
index++;
|
||||
}
|
||||
else
|
||||
{
|
||||
groups.Add(new(index, 1, ChatMessageGroupKind.Other));
|
||||
index++;
|
||||
}
|
||||
}
|
||||
|
||||
return new()
|
||||
{
|
||||
TokenCount = totalTokens,
|
||||
ByteCount = totalBytes,
|
||||
MessageCount = messages.Count,
|
||||
ToolCallCount = toolCallCount,
|
||||
UserTurnCount = userTurnCount,
|
||||
Groups = groups
|
||||
};
|
||||
}
|
||||
|
||||
private void AccumulateMessageMetrics(ChatMessage message, ref int totalTokens, ref long totalBytes, ref int toolCallCount)
|
||||
{
|
||||
string serialized = message.Text;
|
||||
|
||||
int charCount = serialized.Length;
|
||||
totalBytes += System.Text.Encoding.UTF8.GetByteCount(serialized);
|
||||
totalTokens += (charCount / this._charsPerToken) + PerMessageOverheadTokens;
|
||||
|
||||
if (message.Contents is not null)
|
||||
{
|
||||
foreach (AIContent content in message.Contents)
|
||||
{
|
||||
if (content is FunctionCallContent)
|
||||
{
|
||||
toolCallCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Compaction;
|
||||
|
||||
// %%% TODO: Is this interface needed? Consider whether the default implementation is sufficient
|
||||
// and whether custom metrics calculators are a realistic extension point.
|
||||
|
||||
/// <summary>
|
||||
/// Computes <see cref="CompactionMetric"/> 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 ???
|
||||
{
|
||||
/// <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);
|
||||
}
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// A compaction strategy that keeps only the most recent user turns and their
|
||||
/// associated response groups, removing older turns to bound conversation length.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This strategy always preserves system messages. It identifies user turns in the
|
||||
/// conversation and keeps the last <c>maxTurns</c> turns along with all response groups
|
||||
/// (assistant replies, tool call groups) that follow each kept turn.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The trigger condition fires only when the number of user turns exceeds <c>maxTurns</c>.
|
||||
/// </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 class SlidingWindowCompactionStrategy : ChatHistoryCompactionStrategy
|
||||
{
|
||||
private readonly int _maxTurns;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SlidingWindowCompactionStrategy"/> class.
|
||||
/// </summary>
|
||||
/// <param name="maxTurns">
|
||||
/// The maximum number of user turns to keep. Older turns and their associated responses are removed.
|
||||
/// </param>
|
||||
public SlidingWindowCompactionStrategy(int maxTurns)
|
||||
: base(new SlidingWindowReducer(maxTurns))
|
||||
{
|
||||
this._maxTurns = maxTurns;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override bool ShouldCompact(CompactionMetric metrics) =>
|
||||
metrics.UserTurnCount > this._maxTurns;
|
||||
|
||||
/// <summary>
|
||||
/// An <see cref="IChatReducer"/> that keeps system messages and the last N user turns
|
||||
/// with all their associated response groups.
|
||||
/// </summary>
|
||||
private sealed class SlidingWindowReducer(int maxTurns) : IChatReducer
|
||||
{
|
||||
public Task<IEnumerable<ChatMessage>> ReduceAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
IReadOnlyList<ChatMessage> messageList = [.. messages];
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// Keep the last maxTurns user turns and everything after the first kept turn
|
||||
int firstKeptTurnIndex = turnGroupIndices.Count - maxTurns;
|
||||
int firstKeptGroupIndex = turnGroupIndices[firstKeptTurnIndex];
|
||||
|
||||
List<ChatMessage> result = new(messageList.Count);
|
||||
for (int gi = 0; gi < groups.Count; gi++)
|
||||
{
|
||||
ChatMessageGroup group = groups[gi];
|
||||
|
||||
// Always keep system messages; keep groups at or after the window start
|
||||
if (group.Kind == ChatMessageGroupKind.System || gi >= firstKeptGroupIndex)
|
||||
{
|
||||
for (int j = group.StartIndex; j < group.StartIndex + group.Count; j++)
|
||||
{
|
||||
result.Add(messageList[j]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult<IEnumerable<ChatMessage>>(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
+166
@@ -0,0 +1,166 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
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 concise summary message that preserves key facts and context.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This strategy sits between tool-result clearing (gentle) and truncation (aggressive) in the
|
||||
/// compaction ladder. Unlike truncation which discards messages entirely, summarization preserves
|
||||
/// the essential information in compressed form, allowing the agent to maintain awareness of
|
||||
/// earlier context.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The strategy protects system messages and the most recent <c>preserveRecentGroups</c>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public class SummarizationCompactionStrategy : ChatHistoryCompactionStrategy
|
||||
{
|
||||
private readonly int _maxTokens;
|
||||
|
||||
/// <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="maxTokens">The maximum token budget. Summarization is triggered when the token count exceeds this value.</param>
|
||||
/// <param name="preserveRecentGroups">
|
||||
/// The number of most-recent non-system message groups to protect from summarization.
|
||||
/// 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"/>,
|
||||
/// a default prompt that emphasizes fact-preservation is used.
|
||||
/// </param>
|
||||
public SummarizationCompactionStrategy(
|
||||
IChatClient chatClient,
|
||||
int maxTokens,
|
||||
int preserveRecentGroups = 4,
|
||||
string? summarizationPrompt = null)
|
||||
: base(new SummarizationReducer(chatClient, preserveRecentGroups, summarizationPrompt ?? DefaultSummarizationPrompt))
|
||||
{
|
||||
this._maxTokens = maxTokens;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override bool ShouldCompact(CompactionMetric metrics) =>
|
||||
metrics.TokenCount > this._maxTokens;
|
||||
|
||||
/// <summary>
|
||||
/// An <see cref="IChatReducer"/> that sends older message groups to an LLM for summarization,
|
||||
/// then replaces them with a single summary message.
|
||||
/// </summary>
|
||||
private sealed class SummarizationReducer : IChatReducer
|
||||
{
|
||||
private readonly IChatClient _chatClient;
|
||||
private readonly int _preserveRecentGroups;
|
||||
private readonly string _summarizationPrompt;
|
||||
|
||||
public SummarizationReducer(IChatClient chatClient, int preserveRecentGroups, string summarizationPrompt)
|
||||
{
|
||||
this._chatClient = Throw.IfNull(chatClient);
|
||||
this._preserveRecentGroups = preserveRecentGroups;
|
||||
this._summarizationPrompt = Throw.IfNullOrEmpty(summarizationPrompt);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<ChatMessage>> ReduceAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
IReadOnlyList<ChatMessage> messageList = [.. messages];
|
||||
IReadOnlyList<ChatMessageGroup> groups = CurrentMetrics.Groups;
|
||||
|
||||
List<ChatMessageGroup> nonSystemGroups = groups.Where(g => g.Kind != ChatMessageGroupKind.System).ToList();
|
||||
int protectedFromIndex = Math.Max(0, nonSystemGroups.Count - this._preserveRecentGroups);
|
||||
|
||||
if (protectedFromIndex == 0)
|
||||
{
|
||||
// Nothing to summarize — all groups are protected
|
||||
return messageList;
|
||||
}
|
||||
|
||||
// Collect messages from groups that will be summarized
|
||||
List<ChatMessage> toSummarize = [];
|
||||
for (int i = 0; i < protectedFromIndex; i++)
|
||||
{
|
||||
ChatMessageGroup group = nonSystemGroups[i];
|
||||
for (int j = group.StartIndex; j < group.StartIndex + group.Count; j++)
|
||||
{
|
||||
toSummarize.Add(messageList[j]);
|
||||
}
|
||||
}
|
||||
|
||||
if (toSummarize.Count == 0)
|
||||
{
|
||||
return messageList;
|
||||
}
|
||||
|
||||
// Build the summarization request
|
||||
List<ChatMessage> summarizationRequest =
|
||||
[
|
||||
new(ChatRole.System, this._summarizationPrompt),
|
||||
.. toSummarize,
|
||||
new(ChatRole.User, "Summarize the conversation above concisely."),
|
||||
];
|
||||
|
||||
ChatResponse response = await this._chatClient.GetResponseAsync(summarizationRequest, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
string summaryText = string.IsNullOrWhiteSpace(response.Text) ? "[Summary unavailable]" : response.Text;
|
||||
|
||||
// Build result: system groups + summary + protected groups
|
||||
List<ChatMessage> result = [];
|
||||
|
||||
// Keep system messages
|
||||
foreach (ChatMessageGroup group in groups)
|
||||
{
|
||||
if (group.Kind == ChatMessageGroupKind.System)
|
||||
{
|
||||
for (int j = group.StartIndex; j < group.StartIndex + group.Count; j++)
|
||||
{
|
||||
result.Add(messageList[j]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Insert summary
|
||||
result.Add(new ChatMessage(ChatRole.Assistant, $"[Summary]\n{summaryText}"));
|
||||
|
||||
// Keep protected groups
|
||||
for (int i = protectedFromIndex; i < nonSystemGroups.Count; i++)
|
||||
{
|
||||
ChatMessageGroup group = nonSystemGroups[i];
|
||||
for (int j = group.StartIndex; j < group.StartIndex + group.Count; j++)
|
||||
{
|
||||
result.Add(messageList[j]);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// A compaction strategy that collapses old assistant-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="ChatMessageGroupKind.AssistantToolGroup"/>
|
||||
/// entries 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>
|
||||
/// The trigger condition fires only when token count exceeds <c>maxTokens</c> and
|
||||
/// there is at least one tool call in the conversation.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public class ToolResultCompactionStrategy : ChatHistoryCompactionStrategy
|
||||
{
|
||||
private readonly int _maxTokens;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ToolResultCompactionStrategy"/> class.
|
||||
/// </summary>
|
||||
/// <param name="maxTokens">The maximum token budget. Tool groups are collapsed when the token count exceeds this value.</param>
|
||||
/// <param name="preserveRecentGroups">
|
||||
/// 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)
|
||||
: base(new ToolResultClearingReducer(preserveRecentGroups))
|
||||
{
|
||||
this._maxTokens = maxTokens;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override bool ShouldCompact(CompactionMetric metrics) =>
|
||||
metrics.TokenCount > this._maxTokens && metrics.ToolCallCount > 0;
|
||||
|
||||
/// <summary>
|
||||
/// An <see cref="IChatReducer"/> that collapses <see cref="ChatMessageGroupKind.AssistantToolGroup"/>
|
||||
/// entries into single summary messages, preserving the most recent groups.
|
||||
/// </summary>
|
||||
private sealed class ToolResultClearingReducer(int preserveRecentGroups) : IChatReducer
|
||||
{
|
||||
public Task<IEnumerable<ChatMessage>> ReduceAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
IReadOnlyList<ChatMessage> messageList = [.. messages];
|
||||
IReadOnlyList<ChatMessageGroup> groups = CurrentMetrics.Groups;
|
||||
|
||||
List<ChatMessageGroup> nonSystemGroups = groups.Where(g => g.Kind != ChatMessageGroupKind.System).ToList();
|
||||
int protectedFromIndex = Math.Max(0, nonSystemGroups.Count - preserveRecentGroups);
|
||||
HashSet<int> protectedGroupStarts = [];
|
||||
for (int i = protectedFromIndex; i < nonSystemGroups.Count; i++)
|
||||
{
|
||||
protectedGroupStarts.Add(nonSystemGroups[i].StartIndex);
|
||||
}
|
||||
|
||||
List<ChatMessage> result = new(messageList.Count);
|
||||
bool anyCollapsed = false;
|
||||
|
||||
foreach (ChatMessageGroup group in groups)
|
||||
{
|
||||
if (group.Kind == ChatMessageGroupKind.AssistantToolGroup && !protectedGroupStarts.Contains(group.StartIndex))
|
||||
{
|
||||
// Collapse this tool group into a single summary message
|
||||
List<string> toolNames = [];
|
||||
for (int j = group.StartIndex; j < group.StartIndex + group.Count; j++)
|
||||
{
|
||||
if (messageList[j].Contents is not null)
|
||||
{
|
||||
foreach (AIContent content in messageList[j].Contents)
|
||||
{
|
||||
if (content is FunctionCallContent fcc)
|
||||
{
|
||||
toolNames.Add(fcc.Name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
string summary = $"[Tool calls: {string.Join(", ", toolNames)}]";
|
||||
result.Add(new ChatMessage(ChatRole.Assistant, summary));
|
||||
anyCollapsed = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Keep this group as-is
|
||||
for (int j = group.StartIndex; j < group.StartIndex + group.Count; j++)
|
||||
{
|
||||
result.Add(messageList[j]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult<IEnumerable<ChatMessage>>(anyCollapsed ? result : messageList);
|
||||
}
|
||||
}
|
||||
}
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// A compaction strategy that removes the oldest message groups until the estimated
|
||||
/// token count is within a specified budget.
|
||||
/// </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>
|
||||
/// The trigger condition fires only when the current token count exceeds <c>maxTokens</c>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public class TruncationCompactionStrategy : ChatHistoryCompactionStrategy
|
||||
{
|
||||
private readonly int _maxTokens;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TruncationCompactionStrategy"/> class.
|
||||
/// </summary>
|
||||
/// <param name="maxTokens">The maximum token budget. Groups are removed until the token count is at or below this value.</param>
|
||||
/// <param name="preserveRecentGroups">
|
||||
/// The minimum number of most-recent non-system message groups to keep.
|
||||
/// Defaults to 1 so that at least the latest exchange is always preserved.
|
||||
/// </param>
|
||||
public TruncationCompactionStrategy(int maxTokens, int preserveRecentGroups = 1)
|
||||
: base(new TruncationReducer(preserveRecentGroups))
|
||||
{
|
||||
this._maxTokens = maxTokens;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override bool ShouldCompact(CompactionMetric metrics) =>
|
||||
metrics.TokenCount > this._maxTokens;
|
||||
|
||||
/// <summary>
|
||||
/// An <see cref="IChatReducer"/> that removes the oldest non-system message groups,
|
||||
/// keeping at least the most recent group.
|
||||
/// </summary>
|
||||
private sealed class TruncationReducer(int preserveRecentGroups) : IChatReducer
|
||||
{
|
||||
public Task<IEnumerable<ChatMessage>> ReduceAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
IReadOnlyList<ChatMessage> messageList = [.. messages];
|
||||
|
||||
List<ChatMessageGroup> removableGroups = CurrentMetrics.Groups.Where(g => g.Kind != ChatMessageGroupKind.System).ToList();
|
||||
|
||||
if (removableGroups.Count == 0)
|
||||
{
|
||||
return Task.FromResult<IEnumerable<ChatMessage>>(messageList);
|
||||
}
|
||||
|
||||
// Remove oldest non-system groups, keeping at least preserveRecentGroups.
|
||||
int maxRemovable = removableGroups.Count - preserveRecentGroups;
|
||||
|
||||
if (maxRemovable <= 0)
|
||||
{
|
||||
return Task.FromResult<IEnumerable<ChatMessage>>(messageList);
|
||||
}
|
||||
|
||||
HashSet<int> removedGroupStarts = [];
|
||||
for (int ri = 0; ri < maxRemovable; ri++)
|
||||
{
|
||||
removedGroupStarts.Add(removableGroups[ri].StartIndex);
|
||||
}
|
||||
|
||||
List<ChatMessage> messagesToKeep = new(messageList.Count);
|
||||
foreach (ChatMessageGroup group in CurrentMetrics.Groups)
|
||||
{
|
||||
if (removedGroupStarts.Contains(group.StartIndex))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
for (int j = group.StartIndex; j < group.StartIndex + group.Count; j++)
|
||||
{
|
||||
messagesToKeep.Add(messageList[j]);
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult<IEnumerable<ChatMessage>>(messagesToKeep);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,10 @@
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Condition="'$(TargetFrameworkIdentifier)' != '.NETCoreApp'">
|
||||
<PackageReference Include="Microsoft.Bcl.HashCode" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="Microsoft.Agents.AI.Abstractions.UnitTests" />
|
||||
</ItemGroup>
|
||||
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Abstractions.UnitTests.Compaction.Internal;
|
||||
using Microsoft.Agents.AI.Compaction;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction;
|
||||
|
||||
public class ChatHistoryCompactionPipelineTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task EmptyStrategies_ReturnsUnmodifiedAsync()
|
||||
{
|
||||
ChatHistoryCompactionPipeline pipeline = new([]);
|
||||
List<ChatMessage> messages = [new(ChatRole.User, "Hello")];
|
||||
|
||||
CompactionPipelineResult result = await pipeline.CompactAsync(messages);
|
||||
|
||||
Assert.False(result.AnyApplied);
|
||||
Assert.Equal(1, result.Before.MessageCount);
|
||||
Assert.Equal(1, result.After.MessageCount);
|
||||
Assert.Empty(result.StrategyResults);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChainsStrategies_InOrderAsync()
|
||||
{
|
||||
ChatHistoryCompactionStrategy[] strategies =
|
||||
[
|
||||
new NeverCompactStrategy(),
|
||||
new RemoveFirstMessageStrategy(),
|
||||
];
|
||||
ChatHistoryCompactionPipeline pipeline = new(strategies);
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new(ChatRole.User, "First"),
|
||||
new(ChatRole.User, "Second"),
|
||||
];
|
||||
|
||||
CompactionPipelineResult result = await pipeline.CompactAsync(messages);
|
||||
|
||||
Assert.True(result.AnyApplied);
|
||||
Assert.Equal(2, result.StrategyResults.Count);
|
||||
Assert.False(result.StrategyResults[0].Applied);
|
||||
Assert.True(result.StrategyResults[1].Applied);
|
||||
Assert.Single(messages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReportsOverallMetricsAsync()
|
||||
{
|
||||
ChatHistoryCompactionPipeline pipeline = new([new RemoveFirstMessageStrategy()]);
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new(ChatRole.User, "First"),
|
||||
new(ChatRole.User, "Second"),
|
||||
new(ChatRole.User, "Third"),
|
||||
];
|
||||
|
||||
CompactionPipelineResult result = await pipeline.CompactAsync(messages);
|
||||
|
||||
Assert.Equal(3, result.Before.MessageCount);
|
||||
Assert.Equal(2, result.After.MessageCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CustomMetricsCalculator_IsUsedAsync()
|
||||
{
|
||||
Moq.Mock<IChatHistoryMetricsCalculator> calcMock = new();
|
||||
calcMock
|
||||
.Setup(c => c.Calculate(Moq.It.IsAny<IReadOnlyList<ChatMessage>>()))
|
||||
.Returns(new CompactionMetric { MessageCount = 42 });
|
||||
|
||||
ChatHistoryCompactionPipeline pipeline = new(calcMock.Object, []);
|
||||
List<ChatMessage> messages = [new(ChatRole.User, "Hello")];
|
||||
|
||||
CompactionPipelineResult result = await pipeline.CompactAsync(messages);
|
||||
|
||||
Assert.Equal(42, result.Before.MessageCount);
|
||||
calcMock.Verify(c => c.Calculate(Moq.It.IsAny<IReadOnlyList<ChatMessage>>()), Moq.Times.AtLeast(2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsync_NonReadOnlyListMessages_WorksAsync()
|
||||
{
|
||||
ChatHistoryCompactionPipeline pipeline = new([new RemoveFirstMessageStrategy()]);
|
||||
NonReadOnlyList<ChatMessage> messages = new(
|
||||
[
|
||||
new(ChatRole.User, "First"),
|
||||
new(ChatRole.User, "Second"),
|
||||
]);
|
||||
|
||||
CompactionPipelineResult result = await pipeline.CompactAsync(messages);
|
||||
|
||||
Assert.True(result.AnyApplied);
|
||||
Assert.Single(messages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReduceAsync_DelegatesCompactionAsync()
|
||||
{
|
||||
ChatHistoryCompactionPipeline pipeline = new([new RemoveFirstMessageStrategy()]);
|
||||
|
||||
ChatMessage[] messages =
|
||||
[
|
||||
new(ChatRole.User, "First"),
|
||||
new(ChatRole.User, "Second"),
|
||||
new(ChatRole.User, "Third"),
|
||||
];
|
||||
|
||||
IEnumerable<ChatMessage> result = await ((IChatReducer)pipeline).ReduceAsync(messages, default);
|
||||
List<ChatMessage> resultList = result.ToList();
|
||||
|
||||
Assert.Equal(2, resultList.Count);
|
||||
Assert.Equal("Second", resultList[0].Text);
|
||||
Assert.Equal("Third", resultList[1].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReduceAsync_EmptyStrategies_ReturnsAllMessagesAsync()
|
||||
{
|
||||
ChatHistoryCompactionPipeline pipeline = new([]);
|
||||
|
||||
ChatMessage[] messages =
|
||||
[
|
||||
new(ChatRole.User, "Hello"),
|
||||
new(ChatRole.User, "World"),
|
||||
];
|
||||
|
||||
IEnumerable<ChatMessage> result = await ((IChatReducer)pipeline).ReduceAsync(messages, default);
|
||||
|
||||
Assert.Equal(2, result.Count());
|
||||
}
|
||||
}
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Abstractions.UnitTests.Compaction.Internal;
|
||||
using Microsoft.Agents.AI.Compaction;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction;
|
||||
|
||||
public class ChatHistoryCompactionStrategyTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ShouldCompactReturnsFalse_SkipsAsync()
|
||||
{
|
||||
NeverCompactStrategy strategy = new();
|
||||
List<ChatMessage> messages = [new(ChatRole.User, "Hello")];
|
||||
DefaultChatHistoryMetricsCalculator calculator = new();
|
||||
|
||||
CompactionResult result = await strategy.CompactAsync(messages, calculator);
|
||||
|
||||
Assert.False(result.Applied);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ShouldCompactReturnsTrue_RunsCompactionAsync()
|
||||
{
|
||||
RemoveFirstMessageStrategy strategy = new();
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new(ChatRole.User, "First"),
|
||||
new(ChatRole.User, "Second"),
|
||||
];
|
||||
DefaultChatHistoryMetricsCalculator calculator = new();
|
||||
|
||||
CompactionResult result = await strategy.CompactAsync(messages, calculator);
|
||||
|
||||
Assert.True(result.Applied);
|
||||
Assert.Single(messages);
|
||||
Assert.Equal("Second", messages[0].Text);
|
||||
Assert.Equal(2, result.Before.MessageCount);
|
||||
Assert.Equal(1, result.After.MessageCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DelegatesToIChatReducerAsync()
|
||||
{
|
||||
Mock<IChatReducer> reducerMock = new();
|
||||
reducerMock
|
||||
.Setup(r => r.ReduceAsync(It.IsAny<IEnumerable<ChatMessage>>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((IEnumerable<ChatMessage> msgs, CancellationToken _) => msgs.Skip(1));
|
||||
|
||||
TestCompactionStrategy strategy = new(reducerMock.Object);
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new(ChatRole.User, "First"),
|
||||
new(ChatRole.User, "Second"),
|
||||
];
|
||||
DefaultChatHistoryMetricsCalculator calculator = new();
|
||||
|
||||
CompactionResult result = await strategy.CompactAsync(messages, calculator);
|
||||
|
||||
Assert.True(result.Applied);
|
||||
Assert.Single(messages);
|
||||
Assert.Equal("Second", messages[0].Text);
|
||||
reducerMock.Verify(r => r.ReduceAsync(It.IsAny<IEnumerable<ChatMessage>>(), It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReducerNoChange_ReturnsFalseAsync()
|
||||
{
|
||||
Mock<IChatReducer> reducerMock = new();
|
||||
reducerMock
|
||||
.Setup(r => r.ReduceAsync(It.IsAny<IEnumerable<ChatMessage>>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((IEnumerable<ChatMessage> msgs, CancellationToken _) => msgs);
|
||||
|
||||
TestCompactionStrategy strategy = new(reducerMock.Object);
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new(ChatRole.User, "Hello"),
|
||||
];
|
||||
DefaultChatHistoryMetricsCalculator calculator = new();
|
||||
|
||||
CompactionResult result = await strategy.CompactAsync(messages, calculator);
|
||||
|
||||
Assert.False(result.Applied);
|
||||
Assert.Single(messages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExposesReducer()
|
||||
{
|
||||
Mock<IChatReducer> reducerMock = new();
|
||||
TestCompactionStrategy strategy = new(reducerMock.Object);
|
||||
|
||||
Assert.Same(reducerMock.Object, strategy.Reducer);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultName_IsReducerTypeName()
|
||||
{
|
||||
Mock<IChatReducer> reducerMock = new();
|
||||
TestCompactionStrategy strategy = new(reducerMock.Object);
|
||||
|
||||
// Moq proxy type name is used since we're using a mock
|
||||
Assert.NotNull(strategy.Name);
|
||||
Assert.NotEmpty(strategy.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConditionDelegate_ReturnsTrue_ShouldCompactReturnsTrue()
|
||||
{
|
||||
Mock<IChatReducer> reducerMock = new();
|
||||
TestCompactionStrategy strategy = new(reducerMock.Object);
|
||||
|
||||
CompactionMetric metrics = new() { TokenCount = 100 };
|
||||
Assert.True(strategy.ShouldCompact(metrics));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConditionDelegate_ReturnsFalse_ShouldCompactReturnsFalse()
|
||||
{
|
||||
Mock<IChatReducer> reducerMock = new();
|
||||
TestCompactionStrategy strategy = new(reducerMock.Object, shouldCompact: false);
|
||||
|
||||
CompactionMetric metrics = new() { TokenCount = 100 };
|
||||
Assert.False(strategy.ShouldCompact(metrics));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsync_NonReadOnlyListMessages_WorksAsync()
|
||||
{
|
||||
RemoveFirstMessageStrategy strategy = new();
|
||||
NonReadOnlyList<ChatMessage> messages = new(
|
||||
[
|
||||
new(ChatRole.User, "First"),
|
||||
new(ChatRole.User, "Second"),
|
||||
]);
|
||||
DefaultChatHistoryMetricsCalculator calculator = new();
|
||||
|
||||
CompactionResult result = await strategy.CompactAsync(messages, calculator);
|
||||
|
||||
Assert.True(result.Applied);
|
||||
Assert.Single(messages);
|
||||
Assert.Equal("Second", messages[0].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CurrentMetrics_OutsideStrategy_Throws()
|
||||
{
|
||||
Assert.Throws<InvalidOperationException>(() => TestCompactionStrategy.GetCurrentMetrics());
|
||||
}
|
||||
|
||||
private sealed class TestCompactionStrategy : ChatHistoryCompactionStrategy
|
||||
{
|
||||
private readonly bool _shouldCompact;
|
||||
|
||||
public TestCompactionStrategy(IChatReducer reducer, bool shouldCompact = true)
|
||||
: base(reducer)
|
||||
{
|
||||
this._shouldCompact = shouldCompact;
|
||||
}
|
||||
|
||||
public override bool ShouldCompact(CompactionMetric metrics) => this._shouldCompact;
|
||||
|
||||
public static CompactionMetric GetCurrentMetrics() => CurrentMetrics;
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI.Compaction;
|
||||
|
||||
namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction;
|
||||
|
||||
public class CompactionMetricTests
|
||||
{
|
||||
[Fact]
|
||||
public void DefaultValues_AreZero()
|
||||
{
|
||||
CompactionMetric metrics = new();
|
||||
Assert.Equal(0, metrics.TokenCount);
|
||||
Assert.Equal(0L, metrics.ByteCount);
|
||||
Assert.Equal(0, metrics.MessageCount);
|
||||
Assert.Equal(0, metrics.ToolCallCount);
|
||||
Assert.Equal(0, metrics.UserTurnCount);
|
||||
Assert.Empty(metrics.Groups);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InitProperties_SetCorrectly()
|
||||
{
|
||||
CompactionMetric metrics = new()
|
||||
{
|
||||
TokenCount = 100,
|
||||
ByteCount = 500,
|
||||
MessageCount = 5,
|
||||
ToolCallCount = 2,
|
||||
UserTurnCount = 3
|
||||
};
|
||||
|
||||
Assert.Equal(100, metrics.TokenCount);
|
||||
Assert.Equal(500L, metrics.ByteCount);
|
||||
Assert.Equal(5, metrics.MessageCount);
|
||||
Assert.Equal(2, metrics.ToolCallCount);
|
||||
Assert.Equal(3, metrics.UserTurnCount);
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
|
||||
using Microsoft.Agents.AI.Compaction;
|
||||
|
||||
namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction;
|
||||
|
||||
public class CompactionPipelineResultTests
|
||||
{
|
||||
[Fact]
|
||||
public void Properties_AreReadable()
|
||||
{
|
||||
CompactionMetric before = new() { MessageCount = 10 };
|
||||
CompactionMetric after = new() { MessageCount = 5 };
|
||||
CompactionResult strategyResult = new("Test", applied: true, before, after);
|
||||
List<CompactionResult> results = [strategyResult];
|
||||
|
||||
CompactionPipelineResult pipelineResult = new(before, after, results);
|
||||
|
||||
Assert.Same(before, pipelineResult.Before);
|
||||
Assert.Same(after, pipelineResult.After);
|
||||
Assert.Single(pipelineResult.StrategyResults);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnyApplied_AllFalse_ReturnsFalse()
|
||||
{
|
||||
CompactionMetric metrics = new() { MessageCount = 5 };
|
||||
CompactionResult skipped = CompactionResult.Skipped("Skip", metrics);
|
||||
CompactionPipelineResult result = new(metrics, metrics, [skipped]);
|
||||
|
||||
Assert.False(result.AnyApplied);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnyApplied_SomeTrue_ReturnsTrue()
|
||||
{
|
||||
CompactionMetric before = new() { MessageCount = 10 };
|
||||
CompactionMetric after = new() { MessageCount = 5 };
|
||||
CompactionResult applied = new("Applied", applied: true, before, after);
|
||||
CompactionPipelineResult result = new(before, after, [applied]);
|
||||
|
||||
Assert.True(result.AnyApplied);
|
||||
}
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI.Compaction;
|
||||
|
||||
namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction;
|
||||
|
||||
public class CompactionResultTests
|
||||
{
|
||||
[Fact]
|
||||
public void Skipped_HasSameBeforeAndAfter()
|
||||
{
|
||||
CompactionMetric metrics = new() { MessageCount = 5, TokenCount = 100 };
|
||||
CompactionResult result = CompactionResult.Skipped("Test", metrics);
|
||||
|
||||
Assert.Equal("Test", result.StrategyName);
|
||||
Assert.False(result.Applied);
|
||||
Assert.Same(metrics, result.Before);
|
||||
Assert.Same(metrics, result.After);
|
||||
}
|
||||
}
|
||||
+341
@@ -0,0 +1,341 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.Agents.AI.Compaction;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction;
|
||||
|
||||
public class DefaultChatHistoryMetricsCalculatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void EmptyList_ReturnsZeroMetrics()
|
||||
{
|
||||
DefaultChatHistoryMetricsCalculator calculator = new();
|
||||
CompactionMetric metrics = calculator.Calculate([]);
|
||||
|
||||
Assert.Equal(0, metrics.TokenCount);
|
||||
Assert.Equal(0L, metrics.ByteCount);
|
||||
Assert.Equal(0, metrics.MessageCount);
|
||||
Assert.Equal(0, metrics.ToolCallCount);
|
||||
Assert.Equal(0, metrics.UserTurnCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CountsMessages()
|
||||
{
|
||||
DefaultChatHistoryMetricsCalculator calculator = new();
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new(ChatRole.User, "Hello"),
|
||||
new(ChatRole.Assistant, "Hi there"),
|
||||
];
|
||||
|
||||
CompactionMetric metrics = calculator.Calculate(messages);
|
||||
|
||||
Assert.Equal(2, metrics.MessageCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CountsUserTurns()
|
||||
{
|
||||
DefaultChatHistoryMetricsCalculator calculator = new();
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new(ChatRole.User, "Hello"),
|
||||
new(ChatRole.Assistant, "Hi"),
|
||||
new(ChatRole.User, "How are you?"),
|
||||
new(ChatRole.Assistant, "Good"),
|
||||
];
|
||||
|
||||
CompactionMetric metrics = calculator.Calculate(messages);
|
||||
|
||||
Assert.Equal(2, metrics.UserTurnCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CountsToolCalls()
|
||||
{
|
||||
DefaultChatHistoryMetricsCalculator calculator = new();
|
||||
ChatMessage assistantMsg = new(ChatRole.Assistant, [
|
||||
new FunctionCallContent("call1", "get_weather", new Dictionary<string, object?> { ["city"] = "NYC" }),
|
||||
new FunctionCallContent("call2", "get_time"),
|
||||
]);
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new(ChatRole.User, "What's the weather?"),
|
||||
assistantMsg,
|
||||
];
|
||||
|
||||
CompactionMetric metrics = calculator.Calculate(messages);
|
||||
|
||||
Assert.Equal(2, metrics.ToolCallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConsecutiveUserMessages_CountAsOneTurn()
|
||||
{
|
||||
DefaultChatHistoryMetricsCalculator calculator = new();
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new(ChatRole.User, "First"),
|
||||
new(ChatRole.User, "Second"),
|
||||
new(ChatRole.Assistant, "Reply"),
|
||||
];
|
||||
|
||||
CompactionMetric metrics = calculator.Calculate(messages);
|
||||
|
||||
Assert.Equal(1, metrics.UserTurnCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TokenCount_IsPositive()
|
||||
{
|
||||
DefaultChatHistoryMetricsCalculator calculator = new();
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new(ChatRole.User, "Hello world"),
|
||||
];
|
||||
|
||||
CompactionMetric metrics = calculator.Calculate(messages);
|
||||
|
||||
Assert.True(metrics.TokenCount > 0);
|
||||
Assert.True(metrics.ByteCount > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NullInput_ReturnsZeroMetrics()
|
||||
{
|
||||
DefaultChatHistoryMetricsCalculator calculator = new();
|
||||
CompactionMetric metrics = calculator.Calculate(null!);
|
||||
|
||||
Assert.Equal(0, metrics.TokenCount);
|
||||
Assert.Equal(0L, metrics.ByteCount);
|
||||
Assert.Equal(0, metrics.MessageCount);
|
||||
Assert.Empty(metrics.Groups);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvalidCharsPerToken_UsesDefault()
|
||||
{
|
||||
// A non-positive charsPerToken should fall back to the default (4)
|
||||
DefaultChatHistoryMetricsCalculator calculator = new(charsPerToken: 0);
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new(ChatRole.User, "Hello world"),
|
||||
];
|
||||
|
||||
CompactionMetric metrics = calculator.Calculate(messages);
|
||||
|
||||
// With default 4 chars/token: "Hello world" = 11 chars → 11/4=2 + 4 overhead = 6 tokens
|
||||
Assert.True(metrics.TokenCount > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NullMessageText_HandledGracefully()
|
||||
{
|
||||
DefaultChatHistoryMetricsCalculator calculator = new();
|
||||
// Message with no text content — Text returns null
|
||||
ChatMessage msg = new() { Role = ChatRole.User };
|
||||
List<ChatMessage> messages = [msg];
|
||||
|
||||
CompactionMetric metrics = calculator.Calculate(messages);
|
||||
|
||||
Assert.Equal(1, metrics.MessageCount);
|
||||
// Null text → empty string → 0 bytes, only overhead tokens
|
||||
Assert.True(metrics.TokenCount > 0); // per-message overhead
|
||||
Assert.Equal(0L, metrics.ByteCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NullContents_SkipsToolCounting()
|
||||
{
|
||||
DefaultChatHistoryMetricsCalculator calculator = new();
|
||||
ChatMessage msg = new(ChatRole.User, "text");
|
||||
msg.Contents = null!;
|
||||
List<ChatMessage> messages = [msg];
|
||||
|
||||
CompactionMetric metrics = calculator.Calculate(messages);
|
||||
|
||||
Assert.Equal(1, metrics.MessageCount);
|
||||
Assert.Equal(0, metrics.ToolCallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MessageWithOnlyNonTextContent_NullTextHandled()
|
||||
{
|
||||
DefaultChatHistoryMetricsCalculator calculator = new();
|
||||
// FunctionCallContent-only message has null Text
|
||||
ChatMessage msg = new(ChatRole.Assistant,
|
||||
[
|
||||
new FunctionCallContent("c1", "func"),
|
||||
]);
|
||||
List<ChatMessage> messages = [msg];
|
||||
|
||||
CompactionMetric metrics = calculator.Calculate(messages);
|
||||
|
||||
Assert.Equal(1, metrics.MessageCount);
|
||||
Assert.Equal(1, metrics.ToolCallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_PopulatesGroupIndex()
|
||||
{
|
||||
DefaultChatHistoryMetricsCalculator calculator = new();
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new(ChatRole.System, "System prompt"),
|
||||
new(ChatRole.User, "Hello"),
|
||||
new(ChatRole.Assistant, "Hi there"),
|
||||
];
|
||||
|
||||
CompactionMetric metrics = calculator.Calculate(messages);
|
||||
|
||||
Assert.Equal(3, metrics.Groups.Count);
|
||||
Assert.Equal(ChatMessageGroupKind.System, metrics.Groups[0].Kind);
|
||||
Assert.Equal(ChatMessageGroupKind.UserTurn, metrics.Groups[1].Kind);
|
||||
Assert.Equal(ChatMessageGroupKind.AssistantPlain, metrics.Groups[2].Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmptyList_GroupIndexIsEmpty()
|
||||
{
|
||||
DefaultChatHistoryMetricsCalculator calculator = new();
|
||||
CompactionMetric metrics = calculator.Calculate([]);
|
||||
|
||||
Assert.Empty(metrics.Groups);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GroupIndex_SystemMessage_IdentifiedCorrectly()
|
||||
{
|
||||
DefaultChatHistoryMetricsCalculator calculator = new();
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new(ChatRole.System, "You are a helpful assistant"),
|
||||
];
|
||||
|
||||
IReadOnlyList<ChatMessageGroup> groups = calculator.Calculate(messages).Groups;
|
||||
|
||||
Assert.Single(groups);
|
||||
Assert.Equal(ChatMessageGroupKind.System, groups[0].Kind);
|
||||
Assert.Equal(0, groups[0].StartIndex);
|
||||
Assert.Equal(1, groups[0].Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GroupIndex_AssistantWithToolCalls_GroupedWithResults()
|
||||
{
|
||||
DefaultChatHistoryMetricsCalculator calculator = new();
|
||||
ChatMessage assistantMsg = new(ChatRole.Assistant, [
|
||||
new FunctionCallContent("call1", "get_weather", new Dictionary<string, object?> { ["city"] = "NYC" }),
|
||||
]);
|
||||
ChatMessage toolResult = new(ChatRole.Tool, [
|
||||
new FunctionResultContent("call1", "Sunny, 72°F"),
|
||||
]);
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new(ChatRole.User, "What's the weather?"),
|
||||
assistantMsg,
|
||||
toolResult,
|
||||
];
|
||||
|
||||
IReadOnlyList<ChatMessageGroup> groups = calculator.Calculate(messages).Groups;
|
||||
|
||||
Assert.Equal(2, groups.Count);
|
||||
Assert.Equal(ChatMessageGroupKind.UserTurn, groups[0].Kind);
|
||||
Assert.Equal(ChatMessageGroupKind.AssistantToolGroup, groups[1].Kind);
|
||||
Assert.Equal(1, groups[1].StartIndex);
|
||||
Assert.Equal(2, groups[1].Count); // assistant + tool result
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GroupIndex_MultipleToolResults_GroupedTogether()
|
||||
{
|
||||
DefaultChatHistoryMetricsCalculator calculator = new();
|
||||
ChatMessage assistantMsg = new(ChatRole.Assistant, [
|
||||
new FunctionCallContent("c1", "func1"),
|
||||
new FunctionCallContent("c2", "func2"),
|
||||
]);
|
||||
ChatMessage tool1 = new(ChatRole.Tool, [new FunctionResultContent("c1", "result1")]);
|
||||
ChatMessage tool2 = new(ChatRole.Tool, [new FunctionResultContent("c2", "result2")]);
|
||||
List<ChatMessage> messages = [assistantMsg, tool1, tool2];
|
||||
|
||||
IReadOnlyList<ChatMessageGroup> groups = calculator.Calculate(messages).Groups;
|
||||
|
||||
Assert.Single(groups);
|
||||
Assert.Equal(ChatMessageGroupKind.AssistantToolGroup, groups[0].Kind);
|
||||
Assert.Equal(3, groups[0].Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GroupIndex_ComplexConversation_CorrectGrouping()
|
||||
{
|
||||
DefaultChatHistoryMetricsCalculator calculator = new();
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new(ChatRole.System, "You are a helper"),
|
||||
new(ChatRole.User, "Hi"),
|
||||
new(ChatRole.Assistant, "Hello!"),
|
||||
new(ChatRole.User, "Get weather"),
|
||||
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "get_weather")]),
|
||||
new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c1", "Sunny")]),
|
||||
new(ChatRole.Assistant, "It's sunny!"),
|
||||
];
|
||||
|
||||
IReadOnlyList<ChatMessageGroup> groups = calculator.Calculate(messages).Groups;
|
||||
|
||||
Assert.Equal(6, groups.Count);
|
||||
Assert.Equal(ChatMessageGroupKind.System, groups[0].Kind);
|
||||
Assert.Equal(ChatMessageGroupKind.UserTurn, groups[1].Kind);
|
||||
Assert.Equal(ChatMessageGroupKind.AssistantPlain, groups[2].Kind);
|
||||
Assert.Equal(ChatMessageGroupKind.UserTurn, groups[3].Kind);
|
||||
Assert.Equal(ChatMessageGroupKind.AssistantToolGroup, groups[4].Kind);
|
||||
Assert.Equal(2, groups[4].Count); // assistant + tool
|
||||
Assert.Equal(ChatMessageGroupKind.AssistantPlain, groups[5].Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GroupIndex_OrphanToolResult_IdentifiedCorrectly()
|
||||
{
|
||||
DefaultChatHistoryMetricsCalculator calculator = new();
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c1", "orphan result")]),
|
||||
];
|
||||
|
||||
IReadOnlyList<ChatMessageGroup> groups = calculator.Calculate(messages).Groups;
|
||||
|
||||
Assert.Single(groups);
|
||||
Assert.Equal(ChatMessageGroupKind.ToolResult, groups[0].Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GroupIndex_UnknownRole_IdentifiedAsOther()
|
||||
{
|
||||
DefaultChatHistoryMetricsCalculator calculator = new();
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new(new ChatRole("custom"), "custom message"),
|
||||
];
|
||||
|
||||
IReadOnlyList<ChatMessageGroup> groups = calculator.Calculate(messages).Groups;
|
||||
|
||||
Assert.Single(groups);
|
||||
Assert.Equal(ChatMessageGroupKind.Other, groups[0].Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GroupIndex_AssistantWithNullContents_ClassifiedAsPlain()
|
||||
{
|
||||
DefaultChatHistoryMetricsCalculator calculator = new();
|
||||
ChatMessage msg = new(ChatRole.Assistant, "reply");
|
||||
msg.Contents = null!;
|
||||
List<ChatMessage> messages = [msg];
|
||||
|
||||
IReadOnlyList<ChatMessageGroup> groups = calculator.Calculate(messages).Groups;
|
||||
|
||||
Assert.Single(groups);
|
||||
Assert.Equal(ChatMessageGroupKind.AssistantPlain, groups[0].Kind);
|
||||
}
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Provides a way to set <see cref="AIAgent.CurrentRunContext"/> in unit tests
|
||||
/// so that the underlying <c>AsyncLocal</c> is populated for code that reads it.
|
||||
/// </summary>
|
||||
internal static class AgentRunContextHarness
|
||||
{
|
||||
private static readonly ContextAgentShim s_instance = new();
|
||||
|
||||
/// <summary>
|
||||
/// Sets <see cref="AIAgent.CurrentRunContext"/> and invokes the provided action.
|
||||
/// </summary>
|
||||
public static void ExecuteWithRunContext(AgentRunContext context, Action action)
|
||||
{
|
||||
Assert.NotNull(context);
|
||||
Assert.NotNull(action);
|
||||
//AgentRunContext context = new(agent, session, messages ?? [], options); // %%% TODO
|
||||
s_instance.Set(context);
|
||||
action.Invoke();
|
||||
}
|
||||
|
||||
// Derived class that exposes the protected setter.
|
||||
private sealed class ContextAgentShim : AIAgent
|
||||
{
|
||||
public void Set(AgentRunContext? value) => CurrentRunContext = value;
|
||||
|
||||
protected override ValueTask<AgentSession> CreateSessionCoreAsync(CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
protected override ValueTask<JsonElement> SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
protected override Task<AgentResponse> RunCoreAsync(IEnumerable<ChatMessage> messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
protected override IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(IEnumerable<ChatMessage> messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Compaction;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction.Internal;
|
||||
|
||||
internal sealed class NeverCompactStrategy : ChatHistoryCompactionStrategy
|
||||
{
|
||||
public NeverCompactStrategy()
|
||||
: base(new NoOpReducer())
|
||||
{
|
||||
}
|
||||
|
||||
public override string Name => "NeverCompact";
|
||||
public override bool ShouldCompact(CompactionMetric metrics) => false;
|
||||
|
||||
private sealed class NoOpReducer : IChatReducer
|
||||
{
|
||||
public Task<IEnumerable<ChatMessage>> ReduceAsync(IEnumerable<ChatMessage> messages, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(messages);
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// An IList<T> that does NOT implement IReadOnlyList<T>,
|
||||
/// used to test the defensive <c>as IReadOnlyList<T> ?? fallback</c> patterns.
|
||||
/// </summary>
|
||||
internal sealed class NonReadOnlyList<T> : IList<T>
|
||||
{
|
||||
private readonly List<T> _inner;
|
||||
|
||||
public NonReadOnlyList(IEnumerable<T> items)
|
||||
{
|
||||
this._inner = items.ToList();
|
||||
}
|
||||
|
||||
public T this[int index]
|
||||
{
|
||||
get => this._inner[index];
|
||||
set => this._inner[index] = value;
|
||||
}
|
||||
|
||||
public int Count => this._inner.Count;
|
||||
public bool IsReadOnly => false;
|
||||
public void Add(T item) => this._inner.Add(item);
|
||||
public void Clear() => this._inner.Clear();
|
||||
public bool Contains(T item) => this._inner.Contains(item);
|
||||
public void CopyTo(T[] array, int arrayIndex) => this._inner.CopyTo(array, arrayIndex);
|
||||
public IEnumerator<T> GetEnumerator() => this._inner.GetEnumerator();
|
||||
public int IndexOf(T item) => this._inner.IndexOf(item);
|
||||
public void Insert(int index, T item) => this._inner.Insert(index, item);
|
||||
public bool Remove(T item) => this._inner.Remove(item);
|
||||
public void RemoveAt(int index) => this._inner.RemoveAt(index);
|
||||
IEnumerator IEnumerable.GetEnumerator() => this.GetEnumerator();
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
// 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;
|
||||
|
||||
namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction.Internal;
|
||||
|
||||
internal sealed class RemoveFirstMessageStrategy : ChatHistoryCompactionStrategy
|
||||
{
|
||||
public RemoveFirstMessageStrategy()
|
||||
: base(new RemoveFirstReducer())
|
||||
{
|
||||
}
|
||||
|
||||
public override string Name => "RemoveFirst";
|
||||
|
||||
public override bool ShouldCompact(CompactionMetric metrics) => metrics.MessageCount > 0;
|
||||
|
||||
private sealed class RemoveFirstReducer : IChatReducer
|
||||
{
|
||||
public Task<IEnumerable<ChatMessage>> ReduceAsync(IEnumerable<ChatMessage> messages, CancellationToken cancellationToken = default)
|
||||
{
|
||||
List<ChatMessage> list = messages.ToList();
|
||||
if (list.Count > 1)
|
||||
{
|
||||
list.RemoveAt(0);
|
||||
}
|
||||
|
||||
return Task.FromResult<IEnumerable<ChatMessage>>(list);
|
||||
}
|
||||
}
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI.Compaction;
|
||||
|
||||
namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction;
|
||||
|
||||
public class MessageGroupTests
|
||||
{
|
||||
[Fact]
|
||||
public void Equality_Works()
|
||||
{
|
||||
ChatMessageGroup a = new(0, 2, ChatMessageGroupKind.AssistantToolGroup);
|
||||
ChatMessageGroup b = new(0, 2, ChatMessageGroupKind.AssistantToolGroup);
|
||||
ChatMessageGroup c = new(1, 2, ChatMessageGroupKind.AssistantToolGroup);
|
||||
|
||||
Assert.Equal(a, b);
|
||||
Assert.True(a == b);
|
||||
Assert.NotEqual(a, c);
|
||||
Assert.True(a != c);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Equals_Object_NullReturnsFalse()
|
||||
{
|
||||
ChatMessageGroup group = new(0, 1, ChatMessageGroupKind.System);
|
||||
|
||||
Assert.False(group.Equals(null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Equals_Object_BoxedMessageGroupReturnsTrue()
|
||||
{
|
||||
ChatMessageGroup group = new(0, 2, ChatMessageGroupKind.AssistantToolGroup);
|
||||
object boxed = new ChatMessageGroup(0, 2, ChatMessageGroupKind.AssistantToolGroup);
|
||||
|
||||
Assert.True(group.Equals(boxed));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Equals_Object_WrongTypeReturnsFalse()
|
||||
{
|
||||
ChatMessageGroup group = new(0, 1, ChatMessageGroupKind.System);
|
||||
|
||||
Assert.False(group.Equals("not a MessageGroup"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetHashCode_ConsistentForEqualInstances()
|
||||
{
|
||||
ChatMessageGroup a = new(0, 2, ChatMessageGroupKind.AssistantToolGroup);
|
||||
ChatMessageGroup b = new(0, 2, ChatMessageGroupKind.AssistantToolGroup);
|
||||
|
||||
Assert.Equal(a.GetHashCode(), b.GetHashCode());
|
||||
}
|
||||
}
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
// 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.Abstractions.UnitTests.Compaction;
|
||||
|
||||
public class SlidingWindowCompactionStrategyTests
|
||||
{
|
||||
[Fact]
|
||||
public void ShouldCompact_UnderLimit_ReturnsFalse()
|
||||
{
|
||||
SlidingWindowCompactionStrategy strategy = new(maxTurns: 10);
|
||||
CompactionMetric metrics = new() { UserTurnCount = 3 };
|
||||
|
||||
Assert.False(strategy.ShouldCompact(metrics));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShouldCompact_OverLimit_ReturnsTrue()
|
||||
{
|
||||
SlidingWindowCompactionStrategy strategy = new(maxTurns: 2);
|
||||
CompactionMetric metrics = new() { UserTurnCount = 5 };
|
||||
|
||||
Assert.True(strategy.ShouldCompact(metrics));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnderLimit_NoChangeAsync()
|
||||
{
|
||||
SlidingWindowCompactionStrategy strategy = new(maxTurns: 10);
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new(ChatRole.User, "Hello"),
|
||||
new(ChatRole.Assistant, "Hi"),
|
||||
];
|
||||
DefaultChatHistoryMetricsCalculator calculator = new();
|
||||
|
||||
CompactionResult result = await strategy.CompactAsync(messages, calculator);
|
||||
|
||||
Assert.False(result.Applied);
|
||||
Assert.Equal(2, messages.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task KeepsLastNTurnsAsync()
|
||||
{
|
||||
SlidingWindowCompactionStrategy strategy = new(maxTurns: 2);
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new(ChatRole.User, "Turn 1"),
|
||||
new(ChatRole.Assistant, "Reply 1"),
|
||||
new(ChatRole.User, "Turn 2"),
|
||||
new(ChatRole.Assistant, "Reply 2"),
|
||||
new(ChatRole.User, "Turn 3"),
|
||||
new(ChatRole.Assistant, "Reply 3"),
|
||||
];
|
||||
DefaultChatHistoryMetricsCalculator calculator = new();
|
||||
|
||||
CompactionResult result = await strategy.CompactAsync(messages, calculator);
|
||||
|
||||
Assert.True(result.Applied);
|
||||
Assert.Equal(4, messages.Count);
|
||||
Assert.Equal("Turn 2", messages[0].Text);
|
||||
Assert.Equal("Reply 2", messages[1].Text);
|
||||
Assert.Equal("Turn 3", messages[2].Text);
|
||||
Assert.Equal("Reply 3", messages[3].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PreservesSystemMessagesAsync()
|
||||
{
|
||||
SlidingWindowCompactionStrategy strategy = new(maxTurns: 1);
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new(ChatRole.System, "You are a helper"),
|
||||
new(ChatRole.User, "Turn 1"),
|
||||
new(ChatRole.Assistant, "Reply 1"),
|
||||
new(ChatRole.User, "Turn 2"),
|
||||
new(ChatRole.Assistant, "Reply 2"),
|
||||
];
|
||||
DefaultChatHistoryMetricsCalculator calculator = new();
|
||||
|
||||
CompactionResult result = await strategy.CompactAsync(messages, calculator);
|
||||
|
||||
Assert.True(result.Applied);
|
||||
Assert.Equal(3, messages.Count);
|
||||
Assert.Equal(ChatRole.System, messages[0].Role);
|
||||
Assert.Equal("You are a helper", messages[0].Text);
|
||||
Assert.Equal("Turn 2", messages[1].Text);
|
||||
Assert.Equal("Reply 2", messages[2].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PreservesToolGroupsWithinKeptTurnsAsync()
|
||||
{
|
||||
SlidingWindowCompactionStrategy strategy = new(maxTurns: 1);
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new(ChatRole.User, "Turn 1"),
|
||||
new(ChatRole.Assistant, "Reply 1"),
|
||||
new(ChatRole.User, "Get weather"),
|
||||
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "get_weather")]),
|
||||
new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c1", "Sunny")]),
|
||||
new(ChatRole.Assistant, "It's sunny!"),
|
||||
];
|
||||
DefaultChatHistoryMetricsCalculator calculator = new();
|
||||
|
||||
CompactionResult result = await strategy.CompactAsync(messages, calculator);
|
||||
|
||||
Assert.True(result.Applied);
|
||||
// Turn 1 dropped, Turn 2 kept (user + assistant-tool-group + plain assistant)
|
||||
Assert.Equal(4, messages.Count);
|
||||
Assert.Equal("Get weather", messages[0].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SingleTurn_AtLimit_NoChangeAsync()
|
||||
{
|
||||
SlidingWindowCompactionStrategy strategy = new(maxTurns: 1);
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new(ChatRole.User, "Hello"),
|
||||
new(ChatRole.Assistant, "Hi"),
|
||||
];
|
||||
DefaultChatHistoryMetricsCalculator calculator = new();
|
||||
|
||||
CompactionResult result = await strategy.CompactAsync(messages, calculator);
|
||||
|
||||
Assert.False(result.Applied);
|
||||
Assert.Equal(2, messages.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DropsResponseGroupsFromOldTurnsAsync()
|
||||
{
|
||||
SlidingWindowCompactionStrategy strategy = new(maxTurns: 1);
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new(ChatRole.User, "Turn 1"),
|
||||
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "search")]),
|
||||
new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c1", "result")]),
|
||||
new(ChatRole.Assistant, "Here's what I found"),
|
||||
new(ChatRole.User, "Turn 2"),
|
||||
new(ChatRole.Assistant, "Reply 2"),
|
||||
];
|
||||
DefaultChatHistoryMetricsCalculator calculator = new();
|
||||
|
||||
CompactionResult result = await strategy.CompactAsync(messages, calculator);
|
||||
|
||||
Assert.True(result.Applied);
|
||||
Assert.Equal(2, messages.Count);
|
||||
Assert.Equal("Turn 2", messages[0].Text);
|
||||
Assert.Equal("Reply 2", messages[1].Text);
|
||||
}
|
||||
}
|
||||
+190
@@ -0,0 +1,190 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Compaction;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction;
|
||||
|
||||
public class SummarizationCompactionStrategyTests
|
||||
{
|
||||
[Fact]
|
||||
public void ShouldCompact_UnderLimit_ReturnsFalse()
|
||||
{
|
||||
Mock<IChatClient> chatClientMock = new();
|
||||
SummarizationCompactionStrategy strategy = new(chatClientMock.Object, maxTokens: 100000);
|
||||
CompactionMetric metrics = new() { TokenCount = 500 };
|
||||
|
||||
Assert.False(strategy.ShouldCompact(metrics));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShouldCompact_OverLimit_ReturnsTrue()
|
||||
{
|
||||
Mock<IChatClient> chatClientMock = new();
|
||||
SummarizationCompactionStrategy strategy = new(chatClientMock.Object, maxTokens: 100);
|
||||
CompactionMetric metrics = new() { TokenCount = 500 };
|
||||
|
||||
Assert.True(strategy.ShouldCompact(metrics));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnderLimit_NoChangeAsync()
|
||||
{
|
||||
Mock<IChatClient> chatClientMock = new();
|
||||
SummarizationCompactionStrategy strategy = new(chatClientMock.Object, maxTokens: 100000);
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new(ChatRole.User, "Hello"),
|
||||
new(ChatRole.Assistant, "Hi"),
|
||||
];
|
||||
DefaultChatHistoryMetricsCalculator calculator = new();
|
||||
|
||||
CompactionResult result = await strategy.CompactAsync(messages, calculator);
|
||||
|
||||
Assert.False(result.Applied);
|
||||
Assert.Equal(2, messages.Count);
|
||||
chatClientMock.Verify(
|
||||
c => c.GetResponseAsync(It.IsAny<IEnumerable<ChatMessage>>(), It.IsAny<ChatOptions>(), It.IsAny<CancellationToken>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SummarizesOldGroupsAsync()
|
||||
{
|
||||
Mock<IChatClient> chatClientMock = new();
|
||||
chatClientMock
|
||||
.Setup(c => c.GetResponseAsync(It.IsAny<IEnumerable<ChatMessage>>(), It.IsAny<ChatOptions>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "User asked about weather. It was sunny.")));
|
||||
|
||||
// preserveRecentGroups=2 means keep last 2 non-system groups
|
||||
SummarizationCompactionStrategy strategy = new(chatClientMock.Object, maxTokens: 1, preserveRecentGroups: 2);
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new(ChatRole.User, "What's the weather?"),
|
||||
new(ChatRole.Assistant, "The weather is sunny and 72°F."),
|
||||
new(ChatRole.User, "How about tomorrow?"),
|
||||
new(ChatRole.Assistant, "Tomorrow will be cloudy."),
|
||||
new(ChatRole.User, "Thanks!"),
|
||||
new(ChatRole.Assistant, "You're welcome!"),
|
||||
];
|
||||
DefaultChatHistoryMetricsCalculator calculator = new();
|
||||
|
||||
CompactionResult result = await strategy.CompactAsync(messages, calculator);
|
||||
|
||||
Assert.True(result.Applied);
|
||||
// 6 groups (3 user + 3 assistant), protect last 2 → summarize first 4 groups
|
||||
// Result: summary + 2 protected groups = 3 messages
|
||||
Assert.Equal(3, messages.Count);
|
||||
Assert.Contains("[Summary]", messages[0].Text);
|
||||
Assert.Contains("sunny", messages[0].Text);
|
||||
Assert.Equal("Thanks!", messages[1].Text);
|
||||
Assert.Equal("You're welcome!", messages[2].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PreservesSystemMessagesAsync()
|
||||
{
|
||||
Mock<IChatClient> chatClientMock = new();
|
||||
chatClientMock
|
||||
.Setup(c => c.GetResponseAsync(It.IsAny<IEnumerable<ChatMessage>>(), It.IsAny<ChatOptions>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "Summary of earlier discussion.")));
|
||||
|
||||
SummarizationCompactionStrategy strategy = new(chatClientMock.Object, maxTokens: 1, preserveRecentGroups: 1);
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new(ChatRole.System, "You are a helper"),
|
||||
new(ChatRole.User, "Turn 1"),
|
||||
new(ChatRole.Assistant, "Reply 1"),
|
||||
new(ChatRole.User, "Turn 2"),
|
||||
new(ChatRole.Assistant, "Reply 2"),
|
||||
];
|
||||
DefaultChatHistoryMetricsCalculator calculator = new();
|
||||
|
||||
CompactionResult result = await strategy.CompactAsync(messages, calculator);
|
||||
|
||||
Assert.True(result.Applied);
|
||||
Assert.Equal(ChatRole.System, messages[0].Role);
|
||||
Assert.Equal("You are a helper", messages[0].Text);
|
||||
Assert.Contains("[Summary]", messages[1].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AllGroupsProtected_NoChangeAsync()
|
||||
{
|
||||
Mock<IChatClient> chatClientMock = new();
|
||||
SummarizationCompactionStrategy strategy = new(chatClientMock.Object, maxTokens: 1, preserveRecentGroups: 10);
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new(ChatRole.User, "Hello"),
|
||||
new(ChatRole.Assistant, "Hi"),
|
||||
];
|
||||
DefaultChatHistoryMetricsCalculator calculator = new();
|
||||
|
||||
CompactionResult result = await strategy.CompactAsync(messages, calculator);
|
||||
|
||||
// All groups protected → nothing to summarize → no change
|
||||
Assert.False(result.Applied);
|
||||
Assert.Equal(2, messages.Count);
|
||||
chatClientMock.Verify(
|
||||
c => c.GetResponseAsync(It.IsAny<IEnumerable<ChatMessage>>(), It.IsAny<ChatOptions>(), It.IsAny<CancellationToken>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CustomPrompt_UsedInRequestAsync()
|
||||
{
|
||||
const string CustomPrompt = "Summarize briefly.";
|
||||
List<ChatMessage>? capturedMessages = null;
|
||||
|
||||
Mock<IChatClient> chatClientMock = new();
|
||||
chatClientMock
|
||||
.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, "Brief summary.")));
|
||||
|
||||
SummarizationCompactionStrategy strategy = new(chatClientMock.Object, maxTokens: 1, preserveRecentGroups: 1, summarizationPrompt: CustomPrompt);
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new(ChatRole.User, "First"),
|
||||
new(ChatRole.Assistant, "Reply"),
|
||||
new(ChatRole.User, "Second"),
|
||||
new(ChatRole.Assistant, "Reply 2"),
|
||||
];
|
||||
DefaultChatHistoryMetricsCalculator calculator = new();
|
||||
|
||||
await strategy.CompactAsync(messages, calculator);
|
||||
|
||||
Assert.NotNull(capturedMessages);
|
||||
// First message in request should be the custom system prompt
|
||||
Assert.Equal(ChatRole.System, capturedMessages![0].Role);
|
||||
Assert.Equal(CustomPrompt, capturedMessages[0].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NullResponseText_UsesFallbackAsync()
|
||||
{
|
||||
Mock<IChatClient> chatClientMock = new();
|
||||
chatClientMock
|
||||
.Setup(c => c.GetResponseAsync(It.IsAny<IEnumerable<ChatMessage>>(), It.IsAny<ChatOptions>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, (string?)null)));
|
||||
|
||||
SummarizationCompactionStrategy strategy = new(chatClientMock.Object, maxTokens: 1, preserveRecentGroups: 1);
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new(ChatRole.User, "First"),
|
||||
new(ChatRole.Assistant, "Reply"),
|
||||
new(ChatRole.User, "Second"),
|
||||
new(ChatRole.Assistant, "Reply 2"),
|
||||
];
|
||||
DefaultChatHistoryMetricsCalculator calculator = new();
|
||||
|
||||
CompactionResult result = await strategy.CompactAsync(messages, calculator);
|
||||
|
||||
Assert.True(result.Applied);
|
||||
Assert.Contains("[Summary unavailable]", messages[0].Text);
|
||||
}
|
||||
}
|
||||
+168
@@ -0,0 +1,168 @@
|
||||
// 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.Abstractions.UnitTests.Compaction;
|
||||
|
||||
public class ToolResultCompactionStrategyTests
|
||||
{
|
||||
[Fact]
|
||||
public void ShouldCompact_UnderLimit_ReturnsFalse()
|
||||
{
|
||||
ToolResultCompactionStrategy strategy = new(maxTokens: 100000);
|
||||
CompactionMetric metrics = new() { TokenCount = 500, ToolCallCount = 3 };
|
||||
|
||||
Assert.False(strategy.ShouldCompact(metrics));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShouldCompact_OverLimitNoToolCalls_ReturnsFalse()
|
||||
{
|
||||
ToolResultCompactionStrategy strategy = new(maxTokens: 100);
|
||||
CompactionMetric metrics = new() { TokenCount = 500, ToolCallCount = 0 };
|
||||
|
||||
Assert.False(strategy.ShouldCompact(metrics));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShouldCompact_OverLimitWithToolCalls_ReturnsTrue()
|
||||
{
|
||||
ToolResultCompactionStrategy strategy = new(maxTokens: 100);
|
||||
CompactionMetric metrics = new() { TokenCount = 500, ToolCallCount = 2 };
|
||||
|
||||
Assert.True(strategy.ShouldCompact(metrics));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnderLimit_NoChangeAsync()
|
||||
{
|
||||
ToolResultCompactionStrategy strategy = new(maxTokens: 100000);
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new(ChatRole.User, "Hello"),
|
||||
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "get_weather")]),
|
||||
new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c1", "Sunny")]),
|
||||
];
|
||||
DefaultChatHistoryMetricsCalculator calculator = new();
|
||||
|
||||
CompactionResult result = await strategy.CompactAsync(messages, calculator);
|
||||
|
||||
Assert.False(result.Applied);
|
||||
Assert.Equal(3, messages.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CollapsesOldToolGroupAsync()
|
||||
{
|
||||
ToolResultCompactionStrategy strategy = new(maxTokens: 1, preserveRecentGroups: 1);
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new(ChatRole.User, "Check weather"),
|
||||
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "get_weather")]),
|
||||
new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c1", "Sunny, 72°F")]),
|
||||
new(ChatRole.User, "Thanks"),
|
||||
new(ChatRole.Assistant, "You're welcome!"),
|
||||
];
|
||||
DefaultChatHistoryMetricsCalculator calculator = new();
|
||||
|
||||
CompactionResult result = await strategy.CompactAsync(messages, calculator);
|
||||
|
||||
Assert.True(result.Applied);
|
||||
// The old tool group (assistant+tool = 2 messages) should be collapsed to 1
|
||||
Assert.Equal(4, messages.Count); // user + [collapsed] + user + assistant
|
||||
Assert.Contains("[Tool calls: get_weather]", messages[1].Text);
|
||||
Assert.Equal(ChatRole.Assistant, messages[1].Role);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProtectsRecentGroupsAsync()
|
||||
{
|
||||
// With preserveRecentGroups=4, all groups are protected (5 groups, protect 4 non-system)
|
||||
ToolResultCompactionStrategy strategy = new(maxTokens: 1, preserveRecentGroups: 10);
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new(ChatRole.User, "Check weather"),
|
||||
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "get_weather")]),
|
||||
new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c1", "Sunny")]),
|
||||
new(ChatRole.User, "Thanks"),
|
||||
new(ChatRole.Assistant, "You're welcome!"),
|
||||
];
|
||||
DefaultChatHistoryMetricsCalculator calculator = new();
|
||||
|
||||
CompactionResult result = await strategy.CompactAsync(messages, calculator);
|
||||
|
||||
// All groups protected, so no collapse
|
||||
Assert.False(result.Applied);
|
||||
Assert.Equal(5, messages.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PreservesSystemMessagesAsync()
|
||||
{
|
||||
ToolResultCompactionStrategy strategy = new(maxTokens: 1, preserveRecentGroups: 1);
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new(ChatRole.System, "You are a helper"),
|
||||
new(ChatRole.User, "Check weather"),
|
||||
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "get_weather")]),
|
||||
new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c1", "Sunny")]),
|
||||
new(ChatRole.User, "Thanks"),
|
||||
new(ChatRole.Assistant, "You're welcome!"),
|
||||
];
|
||||
DefaultChatHistoryMetricsCalculator calculator = new();
|
||||
|
||||
CompactionResult result = await strategy.CompactAsync(messages, calculator);
|
||||
|
||||
Assert.True(result.Applied);
|
||||
Assert.Equal(ChatRole.System, messages[0].Role);
|
||||
Assert.Equal("You are a helper", messages[0].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MultipleToolCalls_ListedInSummaryAsync()
|
||||
{
|
||||
ToolResultCompactionStrategy strategy = new(maxTokens: 1, preserveRecentGroups: 1);
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new(ChatRole.User, "Do research"),
|
||||
new ChatMessage(ChatRole.Assistant, [
|
||||
new FunctionCallContent("c1", "search"),
|
||||
new FunctionCallContent("c2", "fetch_page"),
|
||||
]),
|
||||
new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c1", "results...")]),
|
||||
new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c2", "page content...")]),
|
||||
new(ChatRole.User, "Summarize"),
|
||||
new(ChatRole.Assistant, "Here's the summary."),
|
||||
];
|
||||
DefaultChatHistoryMetricsCalculator calculator = new();
|
||||
|
||||
CompactionResult result = await strategy.CompactAsync(messages, calculator);
|
||||
|
||||
Assert.True(result.Applied);
|
||||
// Old tool group (1 assistant + 2 tools = 3 messages) collapsed to 1
|
||||
Assert.Equal(4, messages.Count);
|
||||
Assert.Contains("search", messages[1].Text);
|
||||
Assert.Contains("fetch_page", messages[1].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NoToolGroups_NoChangeAsync()
|
||||
{
|
||||
ToolResultCompactionStrategy strategy = new(maxTokens: 1);
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new(ChatRole.User, "Hello"),
|
||||
new(ChatRole.Assistant, "Hi there"),
|
||||
];
|
||||
DefaultChatHistoryMetricsCalculator calculator = new();
|
||||
|
||||
// ToolCallCount is 0, so ShouldCompact returns false
|
||||
CompactionResult result = await strategy.CompactAsync(messages, calculator);
|
||||
|
||||
Assert.False(result.Applied);
|
||||
Assert.Equal(2, messages.Count);
|
||||
}
|
||||
}
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
// 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.Abstractions.UnitTests.Compaction;
|
||||
|
||||
public class TruncationCompactionStrategyTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task UnderLimit_NoChangeAsync()
|
||||
{
|
||||
TruncationCompactionStrategy strategy = new(maxTokens: 100000);
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new(ChatRole.User, "Hello"),
|
||||
];
|
||||
DefaultChatHistoryMetricsCalculator calculator = new();
|
||||
|
||||
CompactionResult result = await strategy.CompactAsync(messages, calculator);
|
||||
|
||||
Assert.False(result.Applied);
|
||||
Assert.Single(messages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OverLimit_RemovesOldestGroupsAsync()
|
||||
{
|
||||
// Use a very low max to trigger compaction
|
||||
TruncationCompactionStrategy strategy = new(maxTokens: 1);
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new(ChatRole.User, "First message"),
|
||||
new(ChatRole.Assistant, "First reply"),
|
||||
new(ChatRole.User, "Second message"),
|
||||
new(ChatRole.Assistant, "Second reply"),
|
||||
];
|
||||
DefaultChatHistoryMetricsCalculator calculator = new();
|
||||
|
||||
CompactionResult result = await strategy.CompactAsync(messages, calculator);
|
||||
|
||||
Assert.True(result.Applied);
|
||||
// Should have removed old groups, keeping at least the last one
|
||||
Assert.True(messages.Count < 4);
|
||||
Assert.True(messages.Count > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShouldCompact_ReturnsFalseWhenUnderLimit()
|
||||
{
|
||||
TruncationCompactionStrategy strategy = new(maxTokens: 10000);
|
||||
CompactionMetric metrics = new() { TokenCount = 500 };
|
||||
|
||||
Assert.False(strategy.ShouldCompact(metrics));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShouldCompact_ReturnsTrueWhenOverLimit()
|
||||
{
|
||||
TruncationCompactionStrategy strategy = new(maxTokens: 100);
|
||||
CompactionMetric metrics = new() { TokenCount = 500 };
|
||||
|
||||
Assert.True(strategy.ShouldCompact(metrics));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SystemOnlyMessages_NoChangeAsync()
|
||||
{
|
||||
// Only system messages → removableGroups.Count == 0 → no change
|
||||
TruncationCompactionStrategy strategy = new(maxTokens: 1);
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new(ChatRole.System, "You are a helper"),
|
||||
];
|
||||
DefaultChatHistoryMetricsCalculator calculator = new();
|
||||
|
||||
CompactionResult result = await strategy.CompactAsync(messages, calculator);
|
||||
|
||||
// ShouldCompact triggers but reducer finds nothing removable
|
||||
Assert.Single(messages);
|
||||
Assert.Equal(result.After.MessageCount, result.Before.MessageCount);
|
||||
Assert.Equal(result.After.TokenCount, result.Before.TokenCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SingleNonSystemGroup_NoChangeAsync()
|
||||
{
|
||||
// Only one non-system group → maxRemovable <= 0 → no change
|
||||
TruncationCompactionStrategy strategy = new(maxTokens: 1);
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new(ChatRole.System, "System prompt"),
|
||||
new(ChatRole.User, "Only user message"),
|
||||
];
|
||||
DefaultChatHistoryMetricsCalculator calculator = new();
|
||||
|
||||
CompactionResult result = await strategy.CompactAsync(messages, calculator);
|
||||
|
||||
Assert.Equal(2, messages.Count);
|
||||
Assert.Equal(result.After.MessageCount, result.Before.MessageCount);
|
||||
Assert.Equal(result.After.TokenCount, result.Before.TokenCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PreserveRecentGroups_KeepsMultipleGroupsAsync()
|
||||
{
|
||||
// preserveRecentGroups=2 means keep at least the last 2 non-system groups
|
||||
TruncationCompactionStrategy strategy = new(maxTokens: 1, preserveRecentGroups: 2);
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new(ChatRole.User, "Turn 1"),
|
||||
new(ChatRole.Assistant, "Reply 1"),
|
||||
new(ChatRole.User, "Turn 2"),
|
||||
new(ChatRole.Assistant, "Reply 2"),
|
||||
new(ChatRole.User, "Turn 3"),
|
||||
new(ChatRole.Assistant, "Reply 3"),
|
||||
];
|
||||
DefaultChatHistoryMetricsCalculator calculator = new();
|
||||
|
||||
CompactionResult result = await strategy.CompactAsync(messages, calculator);
|
||||
|
||||
Assert.True(result.Applied);
|
||||
// 6 groups (3 user + 3 assistant), protect last 2 → 4 removable
|
||||
// Should keep at least: Turn 3 user + Reply 3 assistant (last 2 groups)
|
||||
Assert.True(messages.Count >= 2);
|
||||
Assert.Equal("Reply 3", messages[^1].Text);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user