Checkpoint

This commit is contained in:
Chris Rickman
2026-03-04 18:12:23 -08:00
Unverified
parent 869e51fdce
commit 23cf75be3c
20 changed files with 2706 additions and 1 deletions
@@ -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);
}
}