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>
|
||||
|
||||
Reference in New Issue
Block a user