mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Checkpoint
This commit is contained in:
@@ -5,6 +5,7 @@ using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Compaction;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
@@ -269,6 +270,35 @@ public abstract class ChatHistoryProvider
|
||||
protected virtual ValueTask StoreChatHistoryAsync(InvokedContext context, CancellationToken cancellationToken = default) =>
|
||||
default;
|
||||
|
||||
/// <summary>
|
||||
/// Compacts the messages in place using the specified compaction strategy before they are stored.
|
||||
/// </summary>
|
||||
/// <param name="messages">The messages to compact. This list is mutated in place.</param>
|
||||
/// <param name="compactionStrategy">The compaction strategy to apply.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
|
||||
/// <returns>A task representing the asynchronous operation. The task result is <see langword="true"/> if compaction occurred.</returns>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This method organizes the messages into atomic <see cref="MessageGroup"/> units,
|
||||
/// applies the compaction strategy, and replaces the contents of the list with the compacted result.
|
||||
/// Tool call groups (assistant message + tool results) are treated as atomic units.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
protected static async Task<bool> CompactMessagesAsync(List<ChatMessage> messages, ICompactionStrategy compactionStrategy, CancellationToken cancellationToken = default)
|
||||
{
|
||||
MessageGroups groups = MessageGroups.Create(messages);
|
||||
|
||||
bool compacted = await compactionStrategy.CompactAsync(groups, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (compacted)
|
||||
{
|
||||
messages.Clear();
|
||||
messages.AddRange(groups.GetIncludedMessages());
|
||||
}
|
||||
|
||||
return compacted;
|
||||
}
|
||||
|
||||
/// <summary>Asks the <see cref="ChatHistoryProvider"/> for an object of the specified type <paramref name="serviceType"/>.</summary>
|
||||
/// <param name="serviceType">The type of object being requested.</param>
|
||||
/// <param name="serviceKey">An optional key that can be used to help identify the target service.</param>
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Microsoft.Agents.AI.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// Defines a strategy for compacting a <see cref="MessageGroups"/> to reduce context size.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Compaction strategies operate on <see cref="MessageGroups"/> instances, which organize messages
|
||||
/// into atomic groups that respect the tool-call/result pairing constraint. Strategies mutate the collection
|
||||
/// in place by marking groups as excluded, removing groups, or replacing message content (e.g., with summaries).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Strategies can be applied at three lifecycle points:
|
||||
/// <list type="bullet">
|
||||
/// <item><description><b>In-run</b>: During the tool loop, before each LLM call, to keep context within token limits.</description></item>
|
||||
/// <item><description><b>Pre-write</b>: Before persisting messages to storage via <see cref="ChatHistoryProvider"/>.</description></item>
|
||||
/// <item><description><b>On existing storage</b>: As a maintenance operation to compact stored history.</description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Multiple strategies can be composed by applying them sequentially to the same <see cref="MessageGroups"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public interface ICompactionStrategy
|
||||
{
|
||||
/// <summary>
|
||||
/// Compacts the specified message groups in place.
|
||||
/// </summary>
|
||||
/// <param name="groups">The message group collection to compact. The strategy mutates this collection in place.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
|
||||
/// <returns>A task representing the asynchronous operation. The task result is <see langword="true"/> if compaction occurred, <see langword="false"/> otherwise.</returns>
|
||||
Task<bool> CompactAsync(MessageGroups groups, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a logical group of <see cref="ChatMessage"/> instances that must be kept or removed together during compaction.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Message groups ensure atomic preservation of related messages. For example, an assistant message
|
||||
/// containing tool calls and its corresponding tool result messages form a <see cref="MessageGroupKind.ToolCall"/>
|
||||
/// group — removing one without the other would cause LLM API errors.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Groups also support exclusion semantics: a group can be marked as excluded (with an optional reason)
|
||||
/// to indicate it should not be included in the messages sent to the model, while still being preserved
|
||||
/// for diagnostics, storage, or later re-inclusion.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Each group tracks its <see cref="MessageCount"/>, <see cref="ByteCount"/>, and <see cref="TokenCount"/>
|
||||
/// so that <see cref="MessageGroups"/> can efficiently aggregate totals across all or only included groups.
|
||||
/// These values are computed by <see cref="MessageGroups.Create"/> and passed into the constructor.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class MessageGroup
|
||||
{
|
||||
/// <summary>
|
||||
/// The <see cref="ChatMessage.AdditionalProperties"/> key used to identify a message as a compaction summary.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When this key is present with a value of <see langword="true"/>, the message is classified as
|
||||
/// <see cref="MessageGroupKind.Summary"/> by <see cref="MessageGroups.Create"/>.
|
||||
/// </remarks>
|
||||
public static readonly string SummaryPropertyKey = "_is_summary";
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="MessageGroup"/> class.
|
||||
/// </summary>
|
||||
/// <param name="kind">The kind of message group.</param>
|
||||
/// <param name="messages">The messages in this group. The list is captured as a read-only snapshot.</param>
|
||||
/// <param name="byteCount">The total UTF-8 byte count of the text content in the messages.</param>
|
||||
/// <param name="tokenCount">The token count for the messages, computed by a tokenizer or estimated.</param>
|
||||
/// <param name="turnIndex">
|
||||
/// The zero-based user turn this group belongs to, or <see langword="null"/> for groups that precede
|
||||
/// the first user message (e.g., system messages).
|
||||
/// </param>
|
||||
public MessageGroup(MessageGroupKind kind, IReadOnlyList<ChatMessage> messages, int byteCount, int tokenCount, int? turnIndex = null)
|
||||
{
|
||||
this.Kind = kind;
|
||||
this.Messages = messages;
|
||||
this.MessageCount = messages.Count;
|
||||
this.ByteCount = byteCount;
|
||||
this.TokenCount = tokenCount;
|
||||
this.TurnIndex = turnIndex;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the kind of this message group.
|
||||
/// </summary>
|
||||
public MessageGroupKind Kind { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the messages in this group.
|
||||
/// </summary>
|
||||
public IReadOnlyList<ChatMessage> Messages { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of messages in this group.
|
||||
/// </summary>
|
||||
public int MessageCount { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total UTF-8 byte count of the text content in this group's messages.
|
||||
/// </summary>
|
||||
public int ByteCount { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the estimated or actual token count for this group's messages.
|
||||
/// </summary>
|
||||
public int TokenCount { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the zero-based user turn index this group belongs to, or <see langword="null"/>
|
||||
/// for groups that precede the first user message (e.g., system messages).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A turn starts with a <see cref="MessageGroupKind.User"/> group and includes all subsequent
|
||||
/// non-user, non-system groups until the next user group or end of conversation.
|
||||
/// </remarks>
|
||||
public int? TurnIndex { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether this group is excluded from the projected message list.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Excluded groups are preserved in the collection for diagnostics or storage purposes
|
||||
/// but are not included when calling <see cref="MessageGroups.GetIncludedMessages"/>.
|
||||
/// </remarks>
|
||||
public bool IsExcluded { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets an optional reason explaining why this group was excluded.
|
||||
/// </summary>
|
||||
public string? ExcludeReason { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Microsoft.Agents.AI.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// Identifies the kind of a <see cref="MessageGroup"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Message groups are used to classify logically related messages that must be kept together
|
||||
/// during compaction operations. For example, an assistant message containing tool calls
|
||||
/// and its corresponding tool result messages form an atomic <see cref="ToolCall"/> group.
|
||||
/// </remarks>
|
||||
public enum MessageGroupKind
|
||||
{
|
||||
/// <summary>
|
||||
/// A system message group containing one or more system messages.
|
||||
/// </summary>
|
||||
System,
|
||||
|
||||
/// <summary>
|
||||
/// A user message group containing a single user message.
|
||||
/// </summary>
|
||||
User,
|
||||
|
||||
/// <summary>
|
||||
/// An assistant message group containing a single assistant text response (no tool calls).
|
||||
/// </summary>
|
||||
AssistantText,
|
||||
|
||||
/// <summary>
|
||||
/// An atomic tool call group containing an assistant message with tool calls
|
||||
/// followed by the corresponding tool result messages.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This group must be treated as an atomic unit during compaction. Removing the assistant
|
||||
/// message without its tool results (or vice versa) will cause LLM API errors.
|
||||
/// </remarks>
|
||||
ToolCall,
|
||||
|
||||
/// <summary>
|
||||
/// A summary message group produced by a compaction strategy (e.g., <c>SummarizationCompactionStrategy</c>).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Summary groups replace previously compacted messages with a condensed representation.
|
||||
/// They are identified by the <see cref="MessageGroup.SummaryPropertyKey"/> metadata entry
|
||||
/// on the underlying <see cref="Microsoft.Extensions.AI.ChatMessage"/>.
|
||||
/// </remarks>
|
||||
Summary,
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.ML.Tokenizers;
|
||||
|
||||
namespace Microsoft.Agents.AI.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a collection of <see cref="MessageGroup"/> instances derived from a flat list of <see cref="ChatMessage"/> objects.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <see cref="MessageGroups"/> provides structural grouping of messages into logical units that
|
||||
/// respect the atomic group preservation constraint: tool call assistant messages and their corresponding
|
||||
/// tool result messages are always grouped together.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This collection supports exclusion-based projection, where groups can be marked as excluded
|
||||
/// without being removed, allowing compaction strategies to toggle visibility while preserving
|
||||
/// the full history for diagnostics or storage.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Each group tracks its own <see cref="MessageGroup.MessageCount"/>, <see cref="MessageGroup.ByteCount"/>,
|
||||
/// and <see cref="MessageGroup.TokenCount"/>. The collection provides aggregate properties for both
|
||||
/// the total (all groups) and included (non-excluded groups only) counts.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class MessageGroups
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the list of message groups in this collection.
|
||||
/// </summary>
|
||||
public IList<MessageGroup> Groups { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the tokenizer used for computing token counts, or <see langword="null"/> if token counts are estimated.
|
||||
/// </summary>
|
||||
public Tokenizer? Tokenizer { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="MessageGroups"/> class with the specified groups.
|
||||
/// </summary>
|
||||
/// <param name="groups">The message groups.</param>
|
||||
/// <param name="tokenizer">An optional tokenizer retained for computing token counts when adding new groups.</param>
|
||||
public MessageGroups(IList<MessageGroup> groups, Tokenizer? tokenizer = null)
|
||||
{
|
||||
this.Groups = groups;
|
||||
this.Tokenizer = tokenizer;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a <see cref="MessageGroups"/> from a flat list of <see cref="ChatMessage"/> instances.
|
||||
/// </summary>
|
||||
/// <param name="messages">The messages to group.</param>
|
||||
/// <param name="tokenizer">
|
||||
/// An optional <see cref="Tokenizer"/> for computing token counts on each group.
|
||||
/// When <see langword="null"/>, token counts are estimated as <c>ByteCount / 4</c>.
|
||||
/// </param>
|
||||
/// <returns>A new <see cref="MessageGroups"/> with messages organized into logical groups.</returns>
|
||||
/// <remarks>
|
||||
/// The grouping algorithm:
|
||||
/// <list type="bullet">
|
||||
/// <item><description>System messages become <see cref="MessageGroupKind.System"/> groups.</description></item>
|
||||
/// <item><description>User messages become <see cref="MessageGroupKind.User"/> groups.</description></item>
|
||||
/// <item><description>Assistant messages with tool calls, followed by their corresponding tool result messages, become <see cref="MessageGroupKind.ToolCall"/> groups.</description></item>
|
||||
/// <item><description>Assistant messages marked with <see cref="MessageGroup.SummaryPropertyKey"/> become <see cref="MessageGroupKind.Summary"/> groups.</description></item>
|
||||
/// <item><description>Assistant messages without tool calls become <see cref="MessageGroupKind.AssistantText"/> groups.</description></item>
|
||||
/// </list>
|
||||
/// </remarks>
|
||||
public static MessageGroups Create(IList<ChatMessage> messages, Tokenizer? tokenizer = null)
|
||||
{
|
||||
List<MessageGroup> groups = [];
|
||||
int index = 0;
|
||||
int currentTurn = 0;
|
||||
|
||||
while (index < messages.Count)
|
||||
{
|
||||
ChatMessage message = messages[index];
|
||||
|
||||
if (message.Role == ChatRole.System)
|
||||
{
|
||||
// System messages are not part of any turn
|
||||
groups.Add(CreateGroup(MessageGroupKind.System, [message], tokenizer, turnIndex: null));
|
||||
index++;
|
||||
}
|
||||
else if (message.Role == ChatRole.User)
|
||||
{
|
||||
currentTurn++;
|
||||
groups.Add(CreateGroup(MessageGroupKind.User, [message], tokenizer, currentTurn));
|
||||
index++;
|
||||
}
|
||||
else if (message.Role == ChatRole.Assistant && HasToolCalls(message))
|
||||
{
|
||||
List<ChatMessage> groupMessages = [message];
|
||||
index++;
|
||||
|
||||
// Collect all subsequent tool result messages
|
||||
while (index < messages.Count && messages[index].Role == ChatRole.Tool)
|
||||
{
|
||||
groupMessages.Add(messages[index]);
|
||||
index++;
|
||||
}
|
||||
|
||||
groups.Add(CreateGroup(MessageGroupKind.ToolCall, groupMessages, tokenizer, currentTurn));
|
||||
}
|
||||
else if (message.Role == ChatRole.Assistant && IsSummaryMessage(message))
|
||||
{
|
||||
groups.Add(CreateGroup(MessageGroupKind.Summary, [message], tokenizer, currentTurn));
|
||||
index++;
|
||||
}
|
||||
else
|
||||
{
|
||||
groups.Add(CreateGroup(MessageGroupKind.AssistantText, [message], tokenizer, currentTurn));
|
||||
index++;
|
||||
}
|
||||
}
|
||||
|
||||
return new MessageGroups(groups, tokenizer);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new <see cref="MessageGroup"/> with byte and token counts computed using this collection's
|
||||
/// <see cref="Tokenizer"/>, and adds it to the <see cref="Groups"/> list at the specified index.
|
||||
/// </summary>
|
||||
/// <param name="index">The zero-based index at which the group should be inserted.</param>
|
||||
/// <param name="kind">The kind of message group.</param>
|
||||
/// <param name="messages">The messages in the group.</param>
|
||||
/// <param name="turnIndex">The optional turn index to assign to the new group.</param>
|
||||
/// <returns>The newly created <see cref="MessageGroup"/>.</returns>
|
||||
public MessageGroup InsertGroup(int index, MessageGroupKind kind, IReadOnlyList<ChatMessage> messages, int? turnIndex = null)
|
||||
{
|
||||
MessageGroup group = CreateGroup(kind, messages, this.Tokenizer, turnIndex);
|
||||
this.Groups.Insert(index, group);
|
||||
return group;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new <see cref="MessageGroup"/> with byte and token counts computed using this collection's
|
||||
/// <see cref="Tokenizer"/>, and appends it to the end of the <see cref="Groups"/> list.
|
||||
/// </summary>
|
||||
/// <param name="kind">The kind of message group.</param>
|
||||
/// <param name="messages">The messages in the group.</param>
|
||||
/// <param name="turnIndex">The optional turn index to assign to the new group.</param>
|
||||
/// <returns>The newly created <see cref="MessageGroup"/>.</returns>
|
||||
public MessageGroup AddGroup(MessageGroupKind kind, IReadOnlyList<ChatMessage> messages, int? turnIndex = null)
|
||||
{
|
||||
MessageGroup group = CreateGroup(kind, messages, this.Tokenizer, turnIndex);
|
||||
this.Groups.Add(group);
|
||||
return group;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns only the messages from groups that are not excluded.
|
||||
/// </summary>
|
||||
/// <returns>A list of <see cref="ChatMessage"/> instances from included groups, in order.</returns>
|
||||
public IEnumerable<ChatMessage> GetIncludedMessages() =>
|
||||
this.Groups.Where(group => !group.IsExcluded).SelectMany(group => group.Messages);
|
||||
|
||||
/// <summary>
|
||||
/// Returns all messages from all groups, including excluded ones.
|
||||
/// </summary>
|
||||
/// <returns>A list of all <see cref="ChatMessage"/> instances, in order.</returns>
|
||||
public IEnumerable<ChatMessage> GetAllMessages() => this.Groups.SelectMany(group => group.Messages);
|
||||
|
||||
#region Total aggregates (all groups, including excluded)
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total number of groups, including excluded ones.
|
||||
/// </summary>
|
||||
public int TotalGroupCount => this.Groups.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total number of messages across all groups, including excluded ones.
|
||||
/// </summary>
|
||||
public int TotalMessageCount => this.Groups.Sum(g => g.MessageCount);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total UTF-8 byte count across all groups, including excluded ones.
|
||||
/// </summary>
|
||||
public int TotalByteCount => this.Groups.Sum(g => g.ByteCount);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total token count across all groups, including excluded ones.
|
||||
/// </summary>
|
||||
public int TotalTokenCount => this.Groups.Sum(g => g.TokenCount);
|
||||
|
||||
#endregion
|
||||
|
||||
#region Included aggregates (non-excluded groups only)
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total number of groups that are not excluded.
|
||||
/// </summary>
|
||||
public int IncludedGroupCount => this.Groups.Count(g => !g.IsExcluded);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total number of messages across all included (non-excluded) groups.
|
||||
/// </summary>
|
||||
public int IncludedMessageCount => this.Groups.Where(g => !g.IsExcluded).Sum(g => g.MessageCount);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total UTF-8 byte count across all included (non-excluded) groups.
|
||||
/// </summary>
|
||||
public int IncludedByteCount => this.Groups.Where(g => !g.IsExcluded).Sum(g => g.ByteCount);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total token count across all included (non-excluded) groups.
|
||||
/// </summary>
|
||||
public int IncludedTokenCount => this.Groups.Where(g => !g.IsExcluded).Sum(g => g.TokenCount);
|
||||
|
||||
#endregion
|
||||
|
||||
#region Turn aggregates
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total number of user turns across all groups (including those with excluded groups).
|
||||
/// </summary>
|
||||
public int TotalTurnCount => this.Groups.Select(group => group.TurnIndex).Distinct().Count(turnIndex => turnIndex is not null);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of user turns that have at least one non-excluded group.
|
||||
/// </summary>
|
||||
public int IncludedTurnCount => this.Groups.Where(group => !group.IsExcluded).Select(group => group.TurnIndex).Distinct().Count(turnIndex => turnIndex is not null);
|
||||
|
||||
/// <summary>
|
||||
/// Returns all groups that belong to the specified user turn.
|
||||
/// </summary>
|
||||
/// <param name="turnIndex">The zero-based turn index.</param>
|
||||
/// <returns>The groups belonging to the turn, in order.</returns>
|
||||
public IEnumerable<MessageGroup> GetTurnGroups(int turnIndex) =>
|
||||
this.Groups.Where(g => g.TurnIndex == turnIndex);
|
||||
|
||||
#endregion
|
||||
|
||||
/// <summary>
|
||||
/// Computes the UTF-8 byte count for a set of messages.
|
||||
/// </summary>
|
||||
/// <param name="messages">The messages to compute byte count for.</param>
|
||||
/// <returns>The total UTF-8 byte count of all message text content.</returns>
|
||||
public static int ComputeByteCount(IReadOnlyList<ChatMessage> messages)
|
||||
{
|
||||
int total = 0;
|
||||
for (int i = 0; i < messages.Count; i++)
|
||||
{
|
||||
string text = messages[i].Text ?? string.Empty;
|
||||
if (text.Length > 0)
|
||||
{
|
||||
total += Encoding.UTF8.GetByteCount(text);
|
||||
}
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the token count for a set of messages using the specified tokenizer.
|
||||
/// </summary>
|
||||
/// <param name="messages">The messages to compute token count for.</param>
|
||||
/// <param name="tokenizer">The tokenizer to use for counting tokens.</param>
|
||||
/// <returns>The total token count across all message text content.</returns>
|
||||
public static int ComputeTokenCount(IReadOnlyList<ChatMessage> messages, Tokenizer tokenizer)
|
||||
{
|
||||
int total = 0;
|
||||
for (int i = 0; i < messages.Count; i++)
|
||||
{
|
||||
string text = messages[i].Text ?? string.Empty;
|
||||
if (text.Length > 0)
|
||||
{
|
||||
total += tokenizer.CountTokens(text);
|
||||
}
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
|
||||
private static MessageGroup CreateGroup(MessageGroupKind kind, IReadOnlyList<ChatMessage> messages, Tokenizer? tokenizer, int? turnIndex)
|
||||
{
|
||||
int byteCount = ComputeByteCount(messages);
|
||||
int tokenCount = tokenizer is not null
|
||||
? ComputeTokenCount(messages, tokenizer)
|
||||
: byteCount / 4;
|
||||
|
||||
return new MessageGroup(kind, messages, byteCount, tokenCount, turnIndex);
|
||||
}
|
||||
|
||||
private static bool HasToolCalls(ChatMessage message)
|
||||
{
|
||||
if (message.Contents is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (AIContent content in message.Contents)
|
||||
{
|
||||
if (content is FunctionCallContent)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool IsSummaryMessage(ChatMessage message)
|
||||
{
|
||||
return message.AdditionalProperties?.TryGetValue(MessageGroup.SummaryPropertyKey, out object? value) is true
|
||||
&& value is true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// A compaction strategy that executes a sequential pipeline of <see cref="ICompactionStrategy"/> instances
|
||||
/// against the same <see cref="MessageGroups"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Each strategy in the pipeline operates on the result of the previous one, enabling composed behaviors
|
||||
/// such as summarizing older messages first and then truncating to fit a token budget.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// When <see cref="EarlyStop"/> is <see langword="true"/> and a <see cref="TargetIncludedGroupCount"/> is configured,
|
||||
/// the pipeline stops executing after a strategy reduces the included group count to or below the target.
|
||||
/// This avoids unnecessary work when an earlier strategy is sufficient.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class PipelineCompactionStrategy : ICompactionStrategy
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="PipelineCompactionStrategy"/> class.
|
||||
/// </summary>
|
||||
/// <param name="strategies">The ordered sequence of strategies to execute. Must not be empty.</param>
|
||||
public PipelineCompactionStrategy(params IEnumerable<ICompactionStrategy> strategies)
|
||||
{
|
||||
this.Strategies = [.. Throw.IfNull(strategies)];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the ordered list of strategies in this pipeline.
|
||||
/// </summary>
|
||||
public IReadOnlyList<ICompactionStrategy> Strategies { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the pipeline should stop executing after a strategy
|
||||
/// brings the included group count to or below <see cref="TargetIncludedGroupCount"/>.
|
||||
/// </summary>
|
||||
/// <value>
|
||||
/// Defaults to <see langword="false"/>, meaning all strategies are always executed.
|
||||
/// </value>
|
||||
public bool EarlyStop { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the target number of included groups at which the pipeline stops
|
||||
/// when <see cref="EarlyStop"/> is <see langword="true"/>.
|
||||
/// </summary>
|
||||
/// <value>
|
||||
/// Defaults to <see langword="null"/>, meaning early stop checks are not performed
|
||||
/// even when <see cref="EarlyStop"/> is <see langword="true"/>.
|
||||
/// </value>
|
||||
public int? TargetIncludedGroupCount { get; set; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<bool> CompactAsync(MessageGroups groups, CancellationToken cancellationToken = default)
|
||||
{
|
||||
bool anyCompacted = false;
|
||||
|
||||
foreach (ICompactionStrategy strategy in this.Strategies)
|
||||
{
|
||||
bool compacted = await strategy.CompactAsync(groups, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (compacted)
|
||||
{
|
||||
anyCompacted = true;
|
||||
}
|
||||
|
||||
if (this.EarlyStop && this.TargetIncludedGroupCount is int targetIncludedGroupCount && groups.IncludedGroupCount <= targetIncludedGroupCount)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return anyCompacted;
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ using System.Linq;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Compaction;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
@@ -46,6 +47,7 @@ public sealed class InMemoryChatHistoryProvider : ChatHistoryProvider
|
||||
options?.JsonSerializerOptions);
|
||||
this.ChatReducer = options?.ChatReducer;
|
||||
this.ReducerTriggerEvent = options?.ReducerTriggerEvent ?? InMemoryChatHistoryProviderOptions.ChatReducerTriggerEvent.BeforeMessagesRetrieval;
|
||||
this.CompactionStrategy = options?.CompactionStrategy;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -61,6 +63,11 @@ public sealed class InMemoryChatHistoryProvider : ChatHistoryProvider
|
||||
/// </summary>
|
||||
public InMemoryChatHistoryProviderOptions.ChatReducerTriggerEvent ReducerTriggerEvent { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the compaction strategy used to compact stored messages. If <see langword="null"/>, no compaction is applied.
|
||||
/// </summary>
|
||||
public ICompactionStrategy? CompactionStrategy { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the chat messages stored for the specified session.
|
||||
/// </summary>
|
||||
@@ -109,6 +116,36 @@ public sealed class InMemoryChatHistoryProvider : ChatHistoryProvider
|
||||
{
|
||||
state.Messages = (await this.ChatReducer.ReduceAsync(state.Messages, cancellationToken).ConfigureAwait(false)).ToList();
|
||||
}
|
||||
|
||||
// Apply compaction strategy if configured (pre-write compaction)
|
||||
if (this.CompactionStrategy is not null)
|
||||
{
|
||||
await CompactMessagesAsync(state.Messages, this.CompactionStrategy, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compacts the stored messages for the specified session using the given or configured compaction strategy.
|
||||
/// </summary>
|
||||
/// <param name="session">The agent session whose stored messages should be compacted.</param>
|
||||
/// <param name="compactionStrategy">
|
||||
/// An optional compaction strategy to use. If <see langword="null"/>, the provider's configured
|
||||
/// <see cref="CompactionStrategy"/> is used. If neither is available, an <see cref="InvalidOperationException"/> is thrown.
|
||||
/// </param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
|
||||
/// <returns>A task representing the asynchronous operation. The task result is <see langword="true"/> if compaction occurred.</returns>
|
||||
/// <exception cref="InvalidOperationException">No compaction strategy is configured or provided.</exception>
|
||||
/// <remarks>
|
||||
/// This method enables on-demand compaction of stored history, for example as a maintenance operation.
|
||||
/// It reads the full stored history, applies the compaction strategy, and writes the compacted result back.
|
||||
/// </remarks>
|
||||
public async Task<bool> CompactStorageAsync(AgentSession? session, ICompactionStrategy? compactionStrategy = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ICompactionStrategy strategy = compactionStrategy ?? this.CompactionStrategy
|
||||
?? throw new InvalidOperationException("No compaction strategy is configured or provided.");
|
||||
|
||||
var state = this._sessionState.GetOrInitializeState(session);
|
||||
return await CompactMessagesAsync(state.Messages, strategy, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Agents.AI.Compaction;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
@@ -73,6 +74,21 @@ public sealed class InMemoryChatHistoryProviderOptions
|
||||
/// </value>
|
||||
public Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>>? ProvideOutputMessageFilter { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets an optional <see cref="ICompactionStrategy"/> to apply to stored messages after new messages are added.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// When set, this strategy is applied to the full stored message list after new messages have been appended.
|
||||
/// This enables pre-write compaction to limit storage size.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The compaction strategy organizes messages into atomic groups (preserving tool-call/result pairings)
|
||||
/// before applying the strategy logic. See <see cref="ICompactionStrategy"/> for details.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public ICompactionStrategy? CompactionStrategy { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Defines the events that can trigger a reducer in the <see cref="InMemoryChatHistoryProvider"/>.
|
||||
/// </summary>
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.AI.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.ML.Tokenizers" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.Agents.AI.Compaction;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
@@ -45,6 +46,26 @@ public sealed class ChatClientAgentOptions
|
||||
/// </summary>
|
||||
public IEnumerable<AIContextProvider>? AIContextProviders { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the <see cref="ICompactionStrategy"/> to use for in-run context compaction.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// When set, this strategy is applied to the message list before each call to the underlying
|
||||
/// <see cref="IChatClient"/> during agent execution. This keeps the context within token limits
|
||||
/// as tool calls accumulate during long-running agent invocations.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The strategy organizes messages into atomic groups (preserving tool-call/result pairings)
|
||||
/// before applying compaction logic. See <see cref="ICompactionStrategy"/> for details.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This is separate from the compaction strategy on <see cref="InMemoryChatHistoryProviderOptions.CompactionStrategy"/>,
|
||||
/// which applies pre-write compaction before storing messages. Both can be used together.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public ICompactionStrategy? CompactionStrategy { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether to use the provided <see cref="IChatClient"/> instance as is,
|
||||
/// without applying any default decorators.
|
||||
@@ -101,6 +122,7 @@ public sealed class ChatClientAgentOptions
|
||||
ChatOptions = this.ChatOptions?.Clone(),
|
||||
ChatHistoryProvider = this.ChatHistoryProvider,
|
||||
AIContextProviders = this.AIContextProviders is null ? null : new List<AIContextProvider>(this.AIContextProviders),
|
||||
CompactionStrategy = this.CompactionStrategy,
|
||||
UseProvidedChatClientAsIs = this.UseProvidedChatClientAsIs,
|
||||
ClearOnChatHistoryProviderConflict = this.ClearOnChatHistoryProviderConflict,
|
||||
WarnOnChatHistoryProviderConflict = this.WarnOnChatHistoryProviderConflict,
|
||||
|
||||
@@ -53,9 +53,16 @@ public static class ChatClientExtensions
|
||||
{
|
||||
var chatBuilder = chatClient.AsBuilder();
|
||||
|
||||
// Add compaction as the innermost middleware so it runs before every LLM call,
|
||||
// including those triggered by tool call iterations within FunctionInvokingChatClient.
|
||||
if (options?.CompactionStrategy is { } compactionStrategy)
|
||||
{
|
||||
chatBuilder.Use(innerClient => new CompactingChatClient(innerClient, compactionStrategy));
|
||||
}
|
||||
|
||||
if (chatClient.GetService<FunctionInvokingChatClient>() is null)
|
||||
{
|
||||
_ = chatBuilder.Use((innerClient, services) =>
|
||||
chatBuilder.Use((innerClient, services) =>
|
||||
{
|
||||
var loggerFactory = services.GetService<ILoggerFactory>();
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Compaction;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
/// <summary>
|
||||
/// A delegating <see cref="IChatClient"/> that applies an <see cref="ICompactionStrategy"/> to the message list
|
||||
/// before each call to the inner chat client.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This client is used for in-run compaction during the tool loop. It is inserted into the
|
||||
/// <see cref="IChatClient"/> pipeline before the <see cref="FunctionInvokingChatClient"/> so that
|
||||
/// compaction is applied before every LLM call, including those triggered by tool call iterations.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The compaction strategy organizes messages into atomic groups (preserving tool-call/result pairings)
|
||||
/// before applying compaction logic. Only included messages are forwarded to the inner client.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class CompactingChatClient : DelegatingChatClient
|
||||
{
|
||||
private readonly ICompactionStrategy _compactionStrategy;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="CompactingChatClient"/> class.
|
||||
/// </summary>
|
||||
/// <param name="innerClient">The inner chat client to delegate to.</param>
|
||||
/// <param name="compactionStrategy">The compaction strategy to apply before each call.</param>
|
||||
public CompactingChatClient(IChatClient innerClient, ICompactionStrategy compactionStrategy)
|
||||
: base(innerClient)
|
||||
{
|
||||
this._compactionStrategy = Throw.IfNull(compactionStrategy);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task<ChatResponse> GetResponseAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
ChatOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
List<ChatMessage> compactedMessages = await this.ApplyCompactionAsync(messages, cancellationToken).ConfigureAwait(false);
|
||||
return await base.GetResponseAsync(compactedMessages, options, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
ChatOptions? options = null,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
List<ChatMessage> compactedMessages = await this.ApplyCompactionAsync(messages, cancellationToken).ConfigureAwait(false);
|
||||
await foreach (var update in base.GetStreamingResponseAsync(compactedMessages, options, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
yield return update;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<List<ChatMessage>> ApplyCompactionAsync(IEnumerable<ChatMessage> messages, CancellationToken cancellationToken)
|
||||
{
|
||||
List<ChatMessage> messageList = messages as List<ChatMessage> ?? [.. messages];
|
||||
MessageGroups groups = MessageGroups.Create(messageList);
|
||||
|
||||
bool compacted = await this._compactionStrategy.CompactAsync(groups, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return compacted ? [.. groups.GetIncludedMessages()] : messageList;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// A compaction strategy that summarizes older message groups using an <see cref="IChatClient"/>,
|
||||
/// replacing them with a single summary message.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// When the number of included message groups exceeds <see cref="MaxGroupsBeforeSummary"/>,
|
||||
/// this strategy extracts the oldest non-system groups (up to the threshold), sends them
|
||||
/// to an <see cref="IChatClient"/> for summarization, and replaces those groups with a single
|
||||
/// assistant message containing the summary.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// System message groups are always preserved and never included in summarization.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class SummarizationCompactionStrategy : ICompactionStrategy
|
||||
{
|
||||
private const string DefaultSummarizationPrompt =
|
||||
"Summarize the following conversation concisely, preserving key facts, decisions, and context. " +
|
||||
"Focus on information that would be needed to continue the conversation effectively.";
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SummarizationCompactionStrategy"/> class.
|
||||
/// </summary>
|
||||
/// <param name="chatClient">The chat client to use for generating summaries.</param>
|
||||
/// <param name="maxGroupsBeforeSummary">The maximum number of included groups allowed before summarization is triggered.</param>
|
||||
/// <param name="summarizationPrompt">Optional custom prompt for the summarization request. If <see langword="null"/>, a default prompt is used.</param>
|
||||
public SummarizationCompactionStrategy(IChatClient chatClient, int maxGroupsBeforeSummary, string? summarizationPrompt = null)
|
||||
{
|
||||
this.ChatClient = Throw.IfNull(chatClient);
|
||||
this.MaxGroupsBeforeSummary = maxGroupsBeforeSummary;
|
||||
this.SummarizationPrompt = summarizationPrompt ?? DefaultSummarizationPrompt;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the chat client used for generating summaries.
|
||||
/// </summary>
|
||||
public IChatClient ChatClient { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the maximum number of included groups allowed before summarization is triggered.
|
||||
/// </summary>
|
||||
public int MaxGroupsBeforeSummary { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the prompt used when requesting summaries from the chat client.
|
||||
/// </summary>
|
||||
public string SummarizationPrompt { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<bool> CompactAsync(MessageGroups groups, CancellationToken cancellationToken = default)
|
||||
{
|
||||
int includedCount = groups.IncludedGroupCount;
|
||||
if (includedCount <= this.MaxGroupsBeforeSummary)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Determine how many groups to summarize (keep the most recent MaxGroupsBeforeSummary groups)
|
||||
int groupsToSummarize = includedCount - this.MaxGroupsBeforeSummary;
|
||||
|
||||
// Collect the oldest non-system included groups for summarization
|
||||
StringBuilder conversationText = new();
|
||||
int summarized = 0;
|
||||
int insertIndex = -1;
|
||||
|
||||
for (int i = 0; i < groups.Groups.Count && summarized < groupsToSummarize; i++)
|
||||
{
|
||||
MessageGroup group = groups.Groups[i];
|
||||
if (group.IsExcluded || group.Kind == MessageGroupKind.System)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (insertIndex < 0)
|
||||
{
|
||||
insertIndex = i;
|
||||
}
|
||||
|
||||
// Build text representation of the group for summarization
|
||||
foreach (ChatMessage message in group.Messages)
|
||||
{
|
||||
string text = message.Text ?? string.Empty;
|
||||
if (!string.IsNullOrEmpty(text))
|
||||
{
|
||||
conversationText.AppendLine($"{message.Role}: {text}");
|
||||
}
|
||||
}
|
||||
|
||||
group.IsExcluded = true;
|
||||
group.ExcludeReason = "Summarized by SummarizationCompactionStrategy";
|
||||
summarized++;
|
||||
}
|
||||
|
||||
if (summarized == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Generate summary using the chat client
|
||||
ChatResponse response = await this.ChatClient.GetResponseAsync(
|
||||
[
|
||||
new ChatMessage(ChatRole.System, this.SummarizationPrompt),
|
||||
new ChatMessage(ChatRole.User, conversationText.ToString()),
|
||||
],
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
string summaryText = response.Text ?? string.Empty;
|
||||
|
||||
// Insert a summary group at the position of the first summarized group
|
||||
ChatMessage summaryMessage = new(ChatRole.Assistant, $"[Summary of earlier conversation]: {summaryText}");
|
||||
(summaryMessage.AdditionalProperties ??= [])[MessageGroup.SummaryPropertyKey] = true;
|
||||
|
||||
if (insertIndex >= 0)
|
||||
{
|
||||
groups.InsertGroup(insertIndex, MessageGroupKind.Summary, [summaryMessage]);
|
||||
}
|
||||
else
|
||||
{
|
||||
groups.AddGroup(MessageGroupKind.Summary, [summaryMessage]);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Microsoft.Agents.AI.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// A compaction strategy that keeps the most recent message groups up to a specified limit,
|
||||
/// optionally preserving system message groups.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This strategy implements a sliding window approach: it marks older groups as excluded
|
||||
/// while keeping the most recent groups within the configured <see cref="MaxGroups"/> limit.
|
||||
/// System message groups can optionally be preserved regardless of their position.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This strategy respects atomic group preservation — tool call groups (assistant message + tool results)
|
||||
/// are always kept or excluded together.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class TruncationCompactionStrategy : ICompactionStrategy
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TruncationCompactionStrategy"/> class.
|
||||
/// </summary>
|
||||
/// <param name="maxGroups">The maximum number of message groups to keep. Must be greater than zero.</param>
|
||||
/// <param name="preserveSystemMessages">Whether to preserve system message groups regardless of position. Defaults to <see langword="true"/>.</param>
|
||||
public TruncationCompactionStrategy(int maxGroups, bool preserveSystemMessages = true)
|
||||
{
|
||||
this.MaxGroups = maxGroups;
|
||||
this.PreserveSystemMessages = preserveSystemMessages;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the maximum number of message groups to retain after compaction.
|
||||
/// </summary>
|
||||
public int MaxGroups { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether system message groups are preserved regardless of their position in the conversation.
|
||||
/// </summary>
|
||||
public bool PreserveSystemMessages { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<bool> CompactAsync(MessageGroups groups, CancellationToken cancellationToken = default)
|
||||
{
|
||||
int includedCount = groups.IncludedGroupCount;
|
||||
if (includedCount <= this.MaxGroups)
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
int excessCount = includedCount - this.MaxGroups;
|
||||
bool compacted = false;
|
||||
|
||||
// Exclude oldest non-system groups first (iterate from the beginning)
|
||||
for (int i = 0; i < groups.Groups.Count && excessCount > 0; i++)
|
||||
{
|
||||
MessageGroup group = groups.Groups[i];
|
||||
if (group.IsExcluded)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (this.PreserveSystemMessages && group.Kind == MessageGroupKind.System)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
group.IsExcluded = true;
|
||||
group.ExcludeReason = "Truncated by TruncationCompactionStrategy";
|
||||
excessCount--;
|
||||
compacted = true;
|
||||
}
|
||||
|
||||
return Task.FromResult(compacted);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user