Merge branch 'main' into feat/durable_task

This commit is contained in:
Shyju Krishnankutty
2026-03-10 18:52:03 -07:00
committed by GitHub
Unverified
43 changed files with 7847 additions and 9 deletions
@@ -79,20 +79,21 @@ public sealed class InMemoryChatHistoryProvider : ChatHistoryProvider
/// <exception cref="ArgumentNullException"><paramref name="messages"/> is <see langword="null"/>.</exception>
public void SetMessages(AgentSession? session, List<ChatMessage> messages)
{
_ = Throw.IfNull(messages);
Throw.IfNull(messages);
var state = this._sessionState.GetOrInitializeState(session);
State state = this._sessionState.GetOrInitializeState(session);
state.Messages = messages;
}
/// <inheritdoc />
protected override async ValueTask<IEnumerable<ChatMessage>> ProvideChatHistoryAsync(InvokingContext context, CancellationToken cancellationToken = default)
{
var state = this._sessionState.GetOrInitializeState(context.Session);
State state = this._sessionState.GetOrInitializeState(context.Session);
if (this.ReducerTriggerEvent is InMemoryChatHistoryProviderOptions.ChatReducerTriggerEvent.BeforeMessagesRetrieval && this.ChatReducer is not null)
{
state.Messages = (await this.ChatReducer.ReduceAsync(state.Messages, cancellationToken).ConfigureAwait(false)).ToList();
// Apply pre-retrieval reduction if configured
await ReduceMessagesAsync(this.ChatReducer, state, cancellationToken).ConfigureAwait(false);
}
return state.Messages;
@@ -101,7 +102,7 @@ public sealed class InMemoryChatHistoryProvider : ChatHistoryProvider
/// <inheritdoc />
protected override async ValueTask StoreChatHistoryAsync(InvokedContext context, CancellationToken cancellationToken = default)
{
var state = this._sessionState.GetOrInitializeState(context.Session);
State state = this._sessionState.GetOrInitializeState(context.Session);
// Add request and response messages to the provider
var allNewMessages = context.RequestMessages.Concat(context.ResponseMessages ?? []);
@@ -109,10 +110,16 @@ public sealed class InMemoryChatHistoryProvider : ChatHistoryProvider
if (this.ReducerTriggerEvent is InMemoryChatHistoryProviderOptions.ChatReducerTriggerEvent.AfterMessageAdded && this.ChatReducer is not null)
{
state.Messages = (await this.ChatReducer.ReduceAsync(state.Messages, cancellationToken).ConfigureAwait(false)).ToList();
// Apply pre-write reduction strategy if configured
await ReduceMessagesAsync(this.ChatReducer, state, cancellationToken).ConfigureAwait(false);
}
}
private static async Task ReduceMessagesAsync(IChatReducer reducer, State state, CancellationToken cancellationToken = default)
{
state.Messages = [.. await reducer.ReduceAsync(state.Messages, cancellationToken).ConfigureAwait(false)];
}
/// <summary>
/// Represents the state of a <see cref="InMemoryChatHistoryProvider"/> stored in the <see cref="AgentSession.StateBag"/>.
/// </summary>
@@ -55,7 +55,7 @@ public static class ChatClientExtensions
if (chatClient.GetService<FunctionInvokingChatClient>() is null)
{
_ = chatBuilder.Use((innerClient, services) =>
chatBuilder.Use((innerClient, services) =>
{
var loggerFactory = services.GetService<ILoggerFactory>();
@@ -0,0 +1,159 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Compaction;
/// <summary>
/// Content-based equality comparison for <see cref="ChatMessage"/> instances.
/// </summary>
internal static class ChatMessageContentEquality
{
/// <summary>
/// Determines whether two <see cref="ChatMessage"/> instances represent the same message by content.
/// </summary>
/// <remarks>
/// When both messages define a <see cref="ChatMessage.MessageId"/>, identity is determined solely
/// by that identifier. Otherwise, the comparison falls through to <see cref="ChatMessage.Role"/>,
/// <see cref="ChatMessage.AuthorName"/>, and each item in <see cref="ChatMessage.Contents"/>.
/// </remarks>
internal static bool ContentEquals(this ChatMessage? message, ChatMessage? other)
{
if (ReferenceEquals(message, other))
{
return true;
}
if (message is null || other is null)
{
return false;
}
// A matching MessageId is sufficient.
if (message.MessageId is not null && other.MessageId is not null)
{
return string.Equals(message.MessageId, other.MessageId, StringComparison.Ordinal);
}
if (message.Role != other.Role)
{
return false;
}
if (!string.Equals(message.AuthorName, other.AuthorName, StringComparison.Ordinal))
{
return false;
}
return ContentsEqual(message.Contents, other.Contents);
}
private static bool ContentsEqual(IList<AIContent> left, IList<AIContent> right)
{
if (left.Count != right.Count)
{
return false;
}
for (int i = 0; i < left.Count; i++)
{
if (!ContentItemEquals(left[i], right[i]))
{
return false;
}
}
return true;
}
private static bool ContentItemEquals(AIContent left, AIContent right)
{
if (ReferenceEquals(left, right))
{
return true;
}
if (left.GetType() != right.GetType())
{
return false;
}
return (left, right) switch
{
(TextContent a, TextContent b) => TextContentEquals(a, b),
(TextReasoningContent a, TextReasoningContent b) => TextReasoningContentEquals(a, b),
(DataContent a, DataContent b) => DataContentEquals(a, b),
(UriContent a, UriContent b) => UriContentEquals(a, b),
(ErrorContent a, ErrorContent b) => ErrorContentEquals(a, b),
(FunctionCallContent a, FunctionCallContent b) => FunctionCallContentEquals(a, b),
(FunctionResultContent a, FunctionResultContent b) => FunctionResultContentEquals(a, b),
(HostedFileContent a, HostedFileContent b) => HostedFileContentEquals(a, b),
(AIContent a, AIContent b) => a.GetType() == b.GetType(),
};
}
private static bool TextContentEquals(TextContent a, TextContent b) =>
string.Equals(a.Text, b.Text, StringComparison.Ordinal);
private static bool TextReasoningContentEquals(TextReasoningContent a, TextReasoningContent b) =>
string.Equals(a.Text, b.Text, StringComparison.Ordinal) &&
string.Equals(a.ProtectedData, b.ProtectedData, StringComparison.Ordinal);
private static bool DataContentEquals(DataContent a, DataContent b) =>
string.Equals(a.MediaType, b.MediaType, StringComparison.Ordinal) &&
string.Equals(a.Name, b.Name, StringComparison.Ordinal) &&
a.Data.Span.SequenceEqual(b.Data.Span);
private static bool UriContentEquals(UriContent a, UriContent b) =>
Equals(a.Uri, b.Uri) &&
string.Equals(a.MediaType, b.MediaType, StringComparison.Ordinal);
private static bool ErrorContentEquals(ErrorContent a, ErrorContent b) =>
string.Equals(a.Message, b.Message, StringComparison.Ordinal) &&
string.Equals(a.ErrorCode, b.ErrorCode, StringComparison.Ordinal) &&
Equals(a.Details, b.Details);
private static bool FunctionCallContentEquals(FunctionCallContent a, FunctionCallContent b) =>
string.Equals(a.CallId, b.CallId, StringComparison.Ordinal) &&
string.Equals(a.Name, b.Name, StringComparison.Ordinal) &&
ArgumentsEqual(a.Arguments, b.Arguments);
private static bool FunctionResultContentEquals(FunctionResultContent a, FunctionResultContent b) =>
string.Equals(a.CallId, b.CallId, StringComparison.Ordinal) &&
Equals(a.Result, b.Result);
private static bool ArgumentsEqual(IDictionary<string, object?>? left, IDictionary<string, object?>? right)
{
if (ReferenceEquals(left, right))
{
return true;
}
if (left is null || right is null)
{
return false;
}
if (left.Count != right.Count)
{
return false;
}
foreach (KeyValuePair<string, object?> entry in left)
{
if (!right.TryGetValue(entry.Key, out object? value) || !Equals(entry.Value, value))
{
return false;
}
}
return true;
}
private static bool HostedFileContentEquals(HostedFileContent a, HostedFileContent b) =>
string.Equals(a.FileId, b.FileId, StringComparison.Ordinal) &&
string.Equals(a.MediaType, b.MediaType, StringComparison.Ordinal) &&
string.Equals(a.Name, b.Name, StringComparison.Ordinal);
}
@@ -0,0 +1,82 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Compaction;
/// <summary>
/// A compaction strategy that delegates to an <see cref="IChatReducer"/> to reduce the conversation's
/// included messages.
/// </summary>
/// <remarks>
/// <para>
/// This strategy bridges the <see cref="IChatReducer"/> abstraction from <c>Microsoft.Extensions.AI</c>
/// into the compaction pipeline. It collects the currently included messages from the
/// <see cref="CompactionMessageIndex"/>, passes them to the reducer, and rebuilds the index from the
/// reduced message list when the reducer produces fewer messages.
/// </para>
/// <para>
/// The <see cref="CompactionTrigger"/> controls when reduction is attempted.
/// Use <see cref="CompactionTriggers"/> for common trigger conditions such as token or message thresholds.
/// </para>
/// <para>
/// Use this strategy when you have an existing <see cref="IChatReducer"/> implementation
/// (such as <c>MessageCountingChatReducer</c>) and want to apply it as part of a
/// <see cref="CompactionStrategy"/> pipeline or as an in-run compaction strategy.
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class ChatReducerCompactionStrategy : CompactionStrategy
{
/// <summary>
/// Initializes a new instance of the <see cref="ChatReducerCompactionStrategy"/> class.
/// </summary>
/// <param name="chatReducer">
/// The <see cref="IChatReducer"/> that performs the message reduction.
/// </param>
/// <param name="trigger">
/// The <see cref="CompactionTrigger"/> that controls when compaction proceeds.
/// </param>
public ChatReducerCompactionStrategy(IChatReducer chatReducer, CompactionTrigger trigger)
: base(trigger)
{
this.ChatReducer = Throw.IfNull(chatReducer);
}
/// <summary>
/// Gets the chat reducer used to reduce messages.
/// </summary>
public IChatReducer ChatReducer { get; }
/// <inheritdoc/>
protected override async ValueTask<bool> CompactCoreAsync(CompactionMessageIndex index, ILogger logger, CancellationToken cancellationToken)
{
// No need to short-circuit on empty conversations, this is handled by <see cref="CompactionStrategy.CompactAsync"/>.
List<ChatMessage> includedMessages = [.. index.GetIncludedMessages()];
IEnumerable<ChatMessage> reduced = await this.ChatReducer.ReduceAsync(includedMessages, cancellationToken).ConfigureAwait(false);
IList<ChatMessage> reducedMessages = reduced as IList<ChatMessage> ?? [.. reduced];
if (reducedMessages.Count >= includedMessages.Count)
{
return false;
}
// Rebuild the index from the reduced messages
CompactionMessageIndex rebuilt = CompactionMessageIndex.Create(reducedMessages, index.Tokenizer);
index.Groups.Clear();
foreach (CompactionMessageGroup group in rebuilt.Groups)
{
index.Groups.Add(group);
}
return true;
}
}
@@ -0,0 +1,55 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI.Compaction;
/// <summary>
/// Identifies the kind of a <see cref="CompactionMessageGroup"/>.
/// </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>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public enum CompactionGroupKind
{
/// <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,
#pragma warning disable IDE0001 // Simplify Names
/// <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="CompactionMessageGroup.SummaryPropertyKey"/> metadata entry
/// on the underlying <see cref="Microsoft.Extensions.AI.ChatMessage"/>.
/// </remarks>
#pragma warning restore IDE0001 // Simplify Names
Summary,
}
@@ -0,0 +1,112 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using Microsoft.Extensions.Logging;
namespace Microsoft.Agents.AI.Compaction;
#pragma warning disable SYSLIB1006 // Multiple logging methods cannot use the same event id within a class
/// <summary>
/// Extensions for logging compaction diagnostics.
/// </summary>
/// <remarks>
/// This extension uses the <see cref="LoggerMessageAttribute"/> to
/// generate logging code at compile time to achieve optimized code.
/// </remarks>
[ExcludeFromCodeCoverage]
internal static partial class CompactionLogMessages
{
/// <summary>
/// Logs when compaction is skipped because the trigger condition was not met.
/// </summary>
[LoggerMessage(
Level = LogLevel.Trace,
Message = "Compaction skipped for {StrategyName}: trigger condition not met or insufficient groups.")]
public static partial void LogCompactionSkipped(
this ILogger logger,
string strategyName);
/// <summary>
/// Logs compaction completion with before/after metrics.
/// </summary>
[LoggerMessage(
Level = LogLevel.Debug,
Message = "Compaction completed: {StrategyName} in {DurationMs}ms — Messages {BeforeMessages}→{AfterMessages}, Groups {BeforeGroups}→{AfterGroups}, Tokens {BeforeTokens}→{AfterTokens}")]
public static partial void LogCompactionCompleted(
this ILogger logger,
string strategyName,
long durationMs,
int beforeMessages,
int afterMessages,
int beforeGroups,
int afterGroups,
int beforeTokens,
int afterTokens);
/// <summary>
/// Logs when the compaction provider skips compaction.
/// </summary>
[LoggerMessage(
Level = LogLevel.Trace,
Message = "CompactionProvider skipped: {Reason}.")]
public static partial void LogCompactionProviderSkipped(
this ILogger logger,
string reason);
/// <summary>
/// Logs when the compaction provider begins applying a compaction strategy.
/// </summary>
[LoggerMessage(
Level = LogLevel.Debug,
Message = "CompactionProvider applying compaction to {MessageCount} messages using {StrategyName}.")]
public static partial void LogCompactionProviderApplying(
this ILogger logger,
int messageCount,
string strategyName);
/// <summary>
/// Logs when the compaction provider has applied compaction with result metrics.
/// </summary>
[LoggerMessage(
Level = LogLevel.Debug,
Message = "CompactionProvider compaction applied: messages {BeforeMessages}→{AfterMessages}.")]
public static partial void LogCompactionProviderApplied(
this ILogger logger,
int beforeMessages,
int afterMessages);
/// <summary>
/// Logs when a summarization LLM call is starting.
/// </summary>
[LoggerMessage(
Level = LogLevel.Debug,
Message = "Summarization starting for {GroupCount} groups ({MessageCount} messages) using {ChatClientType}.")]
public static partial void LogSummarizationStarting(
this ILogger logger,
int groupCount,
int messageCount,
string chatClientType);
/// <summary>
/// Logs when a summarization LLM call has completed.
/// </summary>
[LoggerMessage(
Level = LogLevel.Debug,
Message = "Summarization completed: summary length {SummaryLength} characters, inserted at index {InsertIndex}.")]
public static partial void LogSummarizationCompleted(
this ILogger logger,
int summaryLength,
int insertIndex);
/// <summary>
/// Logs when a summarization LLM call fails and groups are restored.
/// </summary>
[LoggerMessage(
Level = LogLevel.Warning,
Message = "Summarization failed for {GroupCount} groups; restoring excluded groups and continuing without compaction. Error: {ErrorMessage}")]
public static partial void LogSummarizationFailed(
this ILogger logger,
int groupCount,
string errorMessage);
}
@@ -0,0 +1,116 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Serialization;
using Microsoft.Extensions.AI;
using Microsoft.Shared.DiagnosticIds;
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="CompactionGroupKind.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="CompactionMessageIndex"/> can efficiently aggregate totals across all or only included groups.
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class CompactionMessageGroup
{
/// <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="CompactionGroupKind.Summary"/> by <see cref="CompactionMessageIndex.Create"/>.
/// </remarks>
public static readonly string SummaryPropertyKey = "_is_summary";
/// <summary>
/// Initializes a new instance of the <see cref="CompactionMessageGroup"/> 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 user turn this group belongs to, or <see langword="null"/> for <see cref="CompactionGroupKind.System"/>.
/// </param>
[JsonConstructor]
internal CompactionMessageGroup(CompactionGroupKind 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 CompactionGroupKind 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 user turn index this group belongs to, or <see langword="null"/> for groups
/// that precede the first user message (e.g., system messages). A turn index of 0
/// corresponds with any non-system message that precedes the first user message,
/// turn index 1 corresponds with the first user message and its subsequent non-user
/// messages, and so on...
/// </summary>
/// <remarks>
/// A turn starts with a <see cref="CompactionGroupKind.User"/> group and includes all subsequent
/// non-user, non-system groups until the next user group or end of conversation. System messages
/// (<see cref="CompactionGroupKind.System"/>) are always assigned a <see langword="null"/> turn index
/// since they never belong to a user turn.
/// </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="CompactionMessageIndex.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,529 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Text;
using Microsoft.Extensions.AI;
using Microsoft.ML.Tokenizers;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Compaction;
/// <summary>
/// A collection of <see cref="CompactionMessageGroup"/> instances and derived metrics based on a flat list of <see cref="ChatMessage"/> objects.
/// </summary>
/// <remarks>
/// <see cref="CompactionMessageIndex"/> provides structural grouping of messages into logical <see cref="CompactionMessageGroup"/> units. Individual
/// groups can be marked as excluded without being removed, allowing compaction strategies to toggle visibility while preserving
/// the full history for diagnostics or storage. Metrics are provided both including and excluding excluded groups,
/// allowing strategies to make informed decisions based on the impact of potential exclusions.
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class CompactionMessageIndex
{
private int _currentTurn;
private ChatMessage? _lastProcessedMessage;
/// <summary>
/// Gets the list of message groups in this collection.
/// </summary>
public IList<CompactionMessageGroup> 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="CompactionMessageIndex"/> 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 CompactionMessageIndex(IList<CompactionMessageGroup> groups, Tokenizer? tokenizer = null)
{
this.Groups = Throw.IfNull(groups, nameof(groups));
this.Tokenizer = tokenizer;
// Restore turn counter and last processed message from the groups
for (int index = groups.Count - 1; index >= 0; --index)
{
if (this._lastProcessedMessage is null && this.Groups[index].Kind != CompactionGroupKind.Summary)
{
IReadOnlyList<ChatMessage> groupMessages = this.Groups[index].Messages;
this._lastProcessedMessage = groupMessages[^1];
}
if (this.Groups[index].TurnIndex.HasValue)
{
this._currentTurn = this.Groups[index].TurnIndex!.Value;
// Both values restored — no need to keep scanning
if (this._lastProcessedMessage is not null)
{
break;
}
}
}
}
/// <summary>
/// Creates a <see cref="CompactionMessageIndex"/> 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="CompactionMessageIndex"/> with messages organized into logical groups.</returns>
/// <remarks>
/// The grouping algorithm:
/// <list type="bullet">
/// <item><description>System messages become <see cref="CompactionGroupKind.System"/> groups.</description></item>
/// <item><description>User messages become <see cref="CompactionGroupKind.User"/> groups.</description></item>
/// <item><description>Assistant messages with tool calls, followed by their corresponding tool result messages, become <see cref="CompactionGroupKind.ToolCall"/> groups.</description></item>
/// <item><description>Assistant messages marked with <see cref="CompactionMessageGroup.SummaryPropertyKey"/> become <see cref="CompactionGroupKind.Summary"/> groups.</description></item>
/// <item><description>Assistant messages without tool calls become <see cref="CompactionGroupKind.AssistantText"/> groups.</description></item>
/// </list>
/// </remarks>
internal static CompactionMessageIndex Create(IList<ChatMessage> messages, Tokenizer? tokenizer = null)
{
CompactionMessageIndex instance = new([], tokenizer);
instance.AppendFromMessages(messages, 0);
return instance;
}
/// <summary>
/// Incrementally updates the groups with new messages from the conversation.
/// </summary>
/// <param name="allMessages">
/// The full list of messages for the conversation. This must be the same list (or a replacement with the same
/// prefix) that was used to create or last update this instance.
/// </param>
/// <remarks>
/// <para>
/// Uses equality on the last processed message to detect changes. Only the messages after that position are
/// processed and appended as new groups. Existing groups and their compaction state (exclusions) are preserved.
/// </para>
/// <para>
/// If the last processed message is not found (e.g., the message list was replaced entirely
/// or a sliding window shifted past it), all groups are cleared and rebuilt from scratch.
/// </para>
/// <para>
/// If the last message in <paramref name="allMessages"/> matches the last
/// processed message, no work is performed.
/// </para>
/// </remarks>
internal void Update(IList<ChatMessage> allMessages)
{
if (allMessages.Count == 0)
{
this.Groups.Clear();
this._currentTurn = 0;
this._lastProcessedMessage = null;
return;
}
// If the last message is unchanged and the list hasn't shrunk, there is nothing new to process.
if (this._lastProcessedMessage is not null &&
allMessages.Count >= this.RawMessageCount &&
allMessages[allMessages.Count - 1].ContentEquals(this._lastProcessedMessage))
{
return;
}
// Walk backwards to locate where we left off.
int foundIndex = -1;
if (this._lastProcessedMessage is not null)
{
for (int i = allMessages.Count - 1; i >= 0; --i)
{
if (allMessages[i].ContentEquals(this._lastProcessedMessage))
{
foundIndex = i;
break;
}
}
}
if (foundIndex < 0)
{
// Last processed message not found — total rebuild.
this.Groups.Clear();
this._currentTurn = 0;
this.AppendFromMessages(allMessages, 0);
return;
}
// Guard against a sliding window that removed messages from the front:
// the number of messages up to (and including) the found position must
// match the number of messages already represented by existing groups.
if (foundIndex + 1 < this.RawMessageCount)
{
// Front of the message list was trimmed — rebuild.
this.Groups.Clear();
this._currentTurn = 0;
this.AppendFromMessages(allMessages, 0);
return;
}
// Process only the delta messages.
this.AppendFromMessages(allMessages, foundIndex + 1);
}
private void AppendFromMessages(IList<ChatMessage> messages, int startIndex)
{
int index = startIndex;
while (index < messages.Count)
{
ChatMessage message = messages[index];
if (message.Role == ChatRole.System)
{
// System messages are not part of any turn
this.Groups.Add(CreateGroup(CompactionGroupKind.System, [message], this.Tokenizer, turnIndex: null));
index++;
}
else if (message.Role == ChatRole.User)
{
this._currentTurn++;
this.Groups.Add(CreateGroup(CompactionGroupKind.User, [message], this.Tokenizer, this._currentTurn));
index++;
}
else if (message.Role == ChatRole.Assistant && HasToolCalls(message))
{
List<ChatMessage> groupMessages = [message];
index++;
// Collect all subsequent tool result messages and reasoning-only assistant messages
while (index < messages.Count &&
(messages[index].Role == ChatRole.Tool ||
(messages[index].Role == ChatRole.Assistant && HasOnlyReasoning(messages[index]))))
{
groupMessages.Add(messages[index]);
index++;
}
this.Groups.Add(CreateGroup(CompactionGroupKind.ToolCall, groupMessages, this.Tokenizer, this._currentTurn));
}
else if (message.Role == ChatRole.Assistant && IsSummaryMessage(message))
{
this.Groups.Add(CreateGroup(CompactionGroupKind.Summary, [message], this.Tokenizer, this._currentTurn));
index++;
}
else if (message.Role == ChatRole.Assistant && HasOnlyReasoning(message))
{
// Reasoning-only assistant messages that precede a tool-call assistant message
// are part of the same atomic tool-call group. Look ahead past consecutive
// reasoning messages to find a possible tool-call message.
int lookahead = index + 1;
while (lookahead < messages.Count &&
messages[lookahead].Role == ChatRole.Assistant &&
HasOnlyReasoning(messages[lookahead]))
{
lookahead++;
}
if (lookahead < messages.Count && messages[lookahead].Role == ChatRole.Assistant && HasToolCalls(messages[lookahead]))
{
// Group all reasoning messages + the tool-call message together
List<ChatMessage> groupMessages = [];
for (int j = index; j <= lookahead; j++)
{
groupMessages.Add(messages[j]);
}
index = lookahead + 1;
// Collect all subsequent tool result messages and reasoning-only assistant messages
while (index < messages.Count &&
(messages[index].Role == ChatRole.Tool ||
(messages[index].Role == ChatRole.Assistant && HasOnlyReasoning(messages[index]))))
{
groupMessages.Add(messages[index]);
index++;
}
this.Groups.Add(CreateGroup(CompactionGroupKind.ToolCall, groupMessages, this.Tokenizer, this._currentTurn));
}
else
{
this.Groups.Add(CreateGroup(CompactionGroupKind.AssistantText, [message], this.Tokenizer, this._currentTurn));
index++;
}
}
else
{
this.Groups.Add(CreateGroup(CompactionGroupKind.AssistantText, [message], this.Tokenizer, this._currentTurn));
index++;
}
}
if (messages.Count > 0)
{
this._lastProcessedMessage = messages[^1];
}
}
/// <summary>
/// Creates a new <see cref="CompactionMessageGroup"/> 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="CompactionMessageGroup"/>.</returns>
public CompactionMessageGroup InsertGroup(int index, CompactionGroupKind kind, IReadOnlyList<ChatMessage> messages, int? turnIndex = null)
{
CompactionMessageGroup group = CreateGroup(kind, messages, this.Tokenizer, turnIndex);
this.Groups.Insert(index, group);
return group;
}
/// <summary>
/// Creates a new <see cref="CompactionMessageGroup"/> 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="CompactionMessageGroup"/>.</returns>
public CompactionMessageGroup AddGroup(CompactionGroupKind kind, IReadOnlyList<ChatMessage> messages, int? turnIndex = null)
{
CompactionMessageGroup 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);
/// <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(group => group.MessageCount);
/// <summary>
/// Gets the total UTF-8 byte count across all groups, including excluded ones.
/// </summary>
public int TotalByteCount => this.Groups.Sum(group => group.ByteCount);
/// <summary>
/// Gets the total token count across all groups, including excluded ones.
/// </summary>
public int TotalTokenCount => this.Groups.Sum(group => group.TokenCount);
/// <summary>
/// Gets the total number of groups that are not excluded.
/// </summary>
public int IncludedGroupCount => this.Groups.Count(group => !group.IsExcluded);
/// <summary>
/// Gets the total number of messages across all included (non-excluded) groups.
/// </summary>
public int IncludedMessageCount => this.Groups.Where(group => !group.IsExcluded).Sum(group => group.MessageCount);
/// <summary>
/// Gets the total UTF-8 byte count across all included (non-excluded) groups.
/// </summary>
public int IncludedByteCount => this.Groups.Where(group => !group.IsExcluded).Sum(group => group.ByteCount);
/// <summary>
/// Gets the total token count across all included (non-excluded) groups.
/// </summary>
public int IncludedTokenCount => this.Groups.Where(group => !group.IsExcluded).Sum(group => group.TokenCount);
/// <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 && turnIndex > 0);
/// <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 && group.TurnIndex is not null && group.TurnIndex > 0).Select(group => group.TurnIndex).Distinct().Count();
/// <summary>
/// Gets the total number of groups across all included (non-excluded) groups that are not <see cref="CompactionGroupKind.System"/>.
/// </summary>
public int IncludedNonSystemGroupCount => this.Groups.Count(group => !group.IsExcluded && group.Kind != CompactionGroupKind.System);
/// <summary>
/// Gets the total number of original messages (that are not summaries).
/// </summary>
public int RawMessageCount => this.Groups.Where(group => group.Kind != CompactionGroupKind.Summary).Sum(group => group.MessageCount);
/// <summary>
/// Returns all groups that belong to the specified user turn.
/// </summary>
/// <param name="turnIndex">The desired turn index.</param>
/// <returns>The groups belonging to the turn, in order.</returns>
public IEnumerable<CompactionMessageGroup> GetTurnGroups(int turnIndex) => this.Groups.Where(group => group.TurnIndex == turnIndex);
/// <summary>
/// Computes the UTF-8 byte count for a set of messages across all content types.
/// </summary>
/// <param name="messages">The messages to compute byte count for.</param>
/// <returns>The total UTF-8 byte count of all message content.</returns>
internal static int ComputeByteCount(IReadOnlyList<ChatMessage> messages)
{
int total = 0;
for (int i = 0; i < messages.Count; i++)
{
IList<AIContent> contents = messages[i].Contents;
for (int j = 0; j < contents.Count; j++)
{
total += ComputeContentByteCount(contents[j]);
}
}
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 content.</returns>
/// <remarks>
/// Text-bearing content (<see cref="TextContent"/> and <see cref="TextReasoningContent"/>)
/// is tokenized directly. All other content types estimate tokens as <c>byteCount / 4</c>.
/// </remarks>
internal static int ComputeTokenCount(IReadOnlyList<ChatMessage> messages, Tokenizer tokenizer)
{
int total = 0;
for (int i = 0; i < messages.Count; i++)
{
IList<AIContent> contents = messages[i].Contents;
for (int j = 0; j < contents.Count; j++)
{
AIContent content = contents[j];
switch (content)
{
case TextContent text:
if (text.Text is { Length: > 0 } t)
{
total += tokenizer.CountTokens(t);
}
break;
case TextReasoningContent reasoning:
if (reasoning.Text is { Length: > 0 } rt)
{
total += tokenizer.CountTokens(rt);
}
if (reasoning.ProtectedData is { Length: > 0 } pd)
{
total += tokenizer.CountTokens(pd);
}
break;
default:
total += ComputeContentByteCount(content) / 4;
break;
}
}
}
return total;
}
private static int ComputeContentByteCount(AIContent content)
{
switch (content)
{
case TextContent text:
return GetStringByteCount(text.Text);
case TextReasoningContent reasoning:
return GetStringByteCount(reasoning.Text) + GetStringByteCount(reasoning.ProtectedData);
case DataContent data:
return data.Data.Length + GetStringByteCount(data.MediaType) + GetStringByteCount(data.Name);
case UriContent uri:
return (uri.Uri is Uri uriValue ? GetStringByteCount(uriValue.OriginalString) : 0) + GetStringByteCount(uri.MediaType);
case FunctionCallContent call:
int callBytes = GetStringByteCount(call.CallId) + GetStringByteCount(call.Name);
if (call.Arguments is not null)
{
foreach (KeyValuePair<string, object?> arg in call.Arguments)
{
callBytes += GetStringByteCount(arg.Key);
callBytes += GetStringByteCount(arg.Value?.ToString());
}
}
return callBytes;
case FunctionResultContent result:
return GetStringByteCount(result.CallId) + GetStringByteCount(result.Result?.ToString());
case ErrorContent error:
return GetStringByteCount(error.Message) + GetStringByteCount(error.ErrorCode) + GetStringByteCount(error.Details);
case HostedFileContent file:
return GetStringByteCount(file.FileId) + GetStringByteCount(file.MediaType) + GetStringByteCount(file.Name);
default:
return 0;
}
}
private static int GetStringByteCount(string? value) =>
value is { Length: > 0 } ? Encoding.UTF8.GetByteCount(value) : 0;
private static CompactionMessageGroup CreateGroup(CompactionGroupKind 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 CompactionMessageGroup(kind, messages, byteCount, tokenCount, turnIndex);
}
private static bool HasToolCalls(ChatMessage message)
{
foreach (AIContent content in message.Contents)
{
if (content is FunctionCallContent)
{
return true;
}
}
return false;
}
private static bool HasOnlyReasoning(ChatMessage message) =>
message.Contents.All(content => content is TextReasoningContent);
private static bool IsSummaryMessage(ChatMessage message) =>
message.AdditionalProperties?.TryGetValue(CompactionMessageGroup.SummaryPropertyKey, out object? value) is true
&& value is true;
}
@@ -0,0 +1,186 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Compaction;
/// <summary>
/// A <see cref="AIContextProvider"/> that applies a <see cref="CompactionStrategy"/> to compact
/// the message list before each agent invocation.
/// </summary>
/// <remarks>
/// <para>
/// This provider performs in-run compaction by organizing messages into atomic groups (preserving
/// tool-call/result pairings) before applying compaction logic. Only included messages are forwarded
/// to the agent's underlying chat client.
/// </para>
/// <para>
/// The <see cref="CompactionProvider"/> can be added to an agent's context provider pipeline
/// via <see cref="ChatClientAgentOptions.AIContextProviders"/> or via <c>UseAIContextProviders</c>
/// on a <see cref="ChatClientBuilder"/> or <see cref="AIAgentBuilder"/>.
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class CompactionProvider : AIContextProvider
{
private readonly CompactionStrategy _compactionStrategy;
private readonly ProviderSessionState<State> _sessionState;
private readonly ILoggerFactory? _loggerFactory;
/// <summary>
/// Initializes a new instance of the <see cref="CompactionProvider"/> class.
/// </summary>
/// <param name="compactionStrategy">The compaction strategy to apply before each invocation.</param>
/// <param name="stateKey">
/// An optional key used to store the provider state in the <see cref="AgentSession.StateBag"/>. Provide
/// an explicit value if configuring multiple agents with different compaction strategies that will interact
/// in the same session.
/// </param>
/// <param name="loggerFactory">
/// An optional <see cref="ILoggerFactory"/> used to create a logger for provider diagnostics.
/// When <see langword="null"/>, logging is disabled.
/// </param>
/// <exception cref="ArgumentNullException"><paramref name="compactionStrategy"/> is <see langword="null"/>.</exception>
public CompactionProvider(CompactionStrategy compactionStrategy, string? stateKey = null, ILoggerFactory? loggerFactory = null)
{
this._compactionStrategy = Throw.IfNull(compactionStrategy);
stateKey ??= this._compactionStrategy.GetType().Name;
this.StateKeys = [stateKey];
this._sessionState = new ProviderSessionState<State>(
_ => new State(),
stateKey,
AgentJsonUtilities.DefaultOptions);
this._loggerFactory = loggerFactory;
}
/// <inheritdoc />
public override IReadOnlyList<string> StateKeys { get; }
/// <summary>
/// Applies compaction strategy to the provided message list and returns the compacted messages.
/// This can be used for ad-hoc compaction outside of the provider pipeline.
/// </summary>
/// <param name="compactionStrategy">The compaction strategy to apply before each invocation.</param>
/// <param name="messages">The messages to compact</param>
/// <param name="logger">An optional <see cref="ILogger"/> for emitting compaction diagnostics.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
/// <returns>An enumeration of the compacted <see cref="ChatMessage"/> instances.</returns>
public static async Task<IEnumerable<ChatMessage>> CompactAsync(CompactionStrategy compactionStrategy, IEnumerable<ChatMessage> messages, ILogger? logger = null, CancellationToken cancellationToken = default)
{
Throw.IfNull(compactionStrategy);
Throw.IfNull(messages);
List<ChatMessage> messageList = messages as List<ChatMessage> ?? [.. messages];
CompactionMessageIndex messageIndex = CompactionMessageIndex.Create(messageList);
await compactionStrategy.CompactAsync(messageIndex, logger, cancellationToken).ConfigureAwait(false);
return messageIndex.GetIncludedMessages();
}
/// <summary>
/// Applies the compaction strategy to the accumulated message list before forwarding it to the agent.
/// </summary>
/// <param name="context">Contains the request context including all accumulated messages.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
/// <returns>
/// A task that represents the asynchronous operation. The task result contains an <see cref="AIContext"/>
/// with the compacted message list.
/// </returns>
protected override async ValueTask<AIContext> InvokingCoreAsync(InvokingContext context, CancellationToken cancellationToken = default)
{
using Activity? activity = CompactionTelemetry.ActivitySource.StartActivity(CompactionTelemetry.ActivityNames.CompactionProviderInvoke);
ILoggerFactory loggerFactory = this.GetLoggerFactory(context.Agent);
ILogger logger = loggerFactory.CreateLogger<CompactionProvider>();
AgentSession? session = context.Session;
IEnumerable<ChatMessage>? allMessages = context.AIContext.Messages;
if (session is null || allMessages is null)
{
logger.LogCompactionProviderSkipped("no session or no messages");
return context.AIContext;
}
ChatClientAgentSession? chatClientSession = session.GetService<ChatClientAgentSession>();
if (chatClientSession is not null &&
!string.IsNullOrWhiteSpace(chatClientSession.ConversationId))
{
logger.LogCompactionProviderSkipped("session managed by remote service");
return context.AIContext;
}
List<ChatMessage> messageList = allMessages as List<ChatMessage> ?? [.. allMessages];
State state = this._sessionState.GetOrInitializeState(session);
CompactionMessageIndex messageIndex;
if (state.MessageGroups.Count > 0)
{
// Update existing index with any new messages appended since the last call.
messageIndex = new([.. state.MessageGroups]);
messageIndex.Update(messageList);
}
else
{
// First pass — initialize the message index from scratch.
messageIndex = CompactionMessageIndex.Create(messageList);
}
string strategyName = this._compactionStrategy.GetType().Name;
int beforeMessages = messageIndex.IncludedMessageCount;
logger.LogCompactionProviderApplying(beforeMessages, strategyName);
// Apply compaction
await this._compactionStrategy.CompactAsync(
messageIndex,
loggerFactory.CreateLogger(this._compactionStrategy.GetType()),
cancellationToken).ConfigureAwait(false);
int afterMessages = messageIndex.IncludedMessageCount;
if (afterMessages < beforeMessages)
{
logger.LogCompactionProviderApplied(beforeMessages, afterMessages);
}
// Persist the index
state.MessageGroups.Clear();
state.MessageGroups.AddRange(messageIndex.Groups);
return new AIContext
{
Instructions = context.AIContext.Instructions,
Messages = messageIndex.GetIncludedMessages(),
Tools = context.AIContext.Tools
};
}
private ILoggerFactory GetLoggerFactory(AIAgent agent) =>
this._loggerFactory ??
agent.GetService<IChatClient>()?.GetService<ILoggerFactory>() ??
NullLoggerFactory.Instance;
/// <summary>
/// Represents the persisted state of a <see cref="CompactionProvider"/> stored in the <see cref="AgentSession.StateBag"/>.
/// </summary>
internal sealed class State
{
/// <summary>
/// Gets or sets the message index groups used for incremental compaction updates.
/// </summary>
[JsonPropertyName("messagegroups")]
public List<CompactionMessageGroup> MessageGroups { get; set; } = [];
}
}
@@ -0,0 +1,164 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Compaction;
/// <summary>
/// Base class for strategies that compact a <see cref="CompactionMessageIndex"/> to reduce context size.
/// </summary>
/// <remarks>
/// <para>
/// Compaction strategies operate on <see cref="CompactionMessageIndex"/> 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>
/// Every strategy requires a <see cref="CompactionTrigger"/> that determines whether compaction should
/// proceed based on current <see cref="CompactionMessageIndex"/> metrics (token count, message count, turn count, etc.).
/// The base class evaluates this trigger at the start of <see cref="CompactAsync"/> and skips compaction when
/// the trigger returns <see langword="false"/>.
/// </para>
/// <para>
/// An optional <b>target</b> condition controls when compaction stops. Strategies incrementally exclude
/// groups and re-evaluate the target after each exclusion, stopping as soon as the target returns
/// <see langword="true"/>. When no target is specified, it defaults to the inverse of the trigger —
/// meaning compaction stops when the trigger condition would no longer fire.
/// </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="CompactionMessageIndex"/>
/// via <see cref="PipelineCompactionStrategy"/>.
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public abstract class CompactionStrategy
{
/// <summary>
/// Initializes a new instance of the <see cref="CompactionStrategy"/> class.
/// </summary>
/// <param name="trigger">
/// The <see cref="CompactionTrigger"/> that determines whether compaction should proceed.
/// </param>
/// <param name="target">
/// An optional target condition that controls when compaction stops. Strategies re-evaluate
/// this predicate after each incremental exclusion and stop when it returns <see langword="true"/>.
/// When <see langword="null"/>, defaults to the inverse of the <paramref name="trigger"/> — compaction
/// stops as soon as the trigger condition would no longer fire.
/// </param>
protected CompactionStrategy(CompactionTrigger trigger, CompactionTrigger? target = null)
{
this.Trigger = Throw.IfNull(trigger);
this.Target = target ?? (index => !trigger(index));
}
/// <summary>
/// Gets the trigger predicate that controls when compaction proceeds.
/// </summary>
protected CompactionTrigger Trigger { get; }
/// <summary>
/// Gets the target predicate that controls when compaction stops.
/// Strategies re-evaluate this after each incremental exclusion and stop when it returns <see langword="true"/>.
/// </summary>
protected CompactionTrigger Target { get; }
/// <summary>
/// Applies the strategy-specific compaction logic to the specified message index.
/// </summary>
/// <remarks>
/// This method is called by <see cref="CompactAsync"/> only when the <see cref="Trigger"/>
/// returns <see langword="true"/>. Implementations do not need to evaluate the trigger or
/// report metrics — the base class handles both. Implementations should use <see cref="Target"/>
/// to determine when to stop compacting incrementally.
/// </remarks>
/// <param name="index">The message index to compact. The strategy mutates this collection in place.</param>
/// <param name="logger">The <see cref="ILogger"/> for emitting compaction diagnostics.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
/// <returns>A task whose result is <see langword="true"/> if any compaction was performed, <see langword="false"/> otherwise.</returns>
protected abstract ValueTask<bool> CompactCoreAsync(CompactionMessageIndex index, ILogger logger, CancellationToken cancellationToken);
/// <summary>
/// Evaluates the <see cref="Trigger"/> and, when it fires, delegates to
/// <see cref="CompactCoreAsync"/> and reports compaction metrics.
/// </summary>
/// <param name="index">The message index to compact. The strategy mutates this collection in place.</param>
/// <param name="logger">An optional <see cref="ILogger"/> for emitting compaction diagnostics. When <see langword="null"/>, logging is disabled.</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>
public async ValueTask<bool> CompactAsync(CompactionMessageIndex index, ILogger? logger = null, CancellationToken cancellationToken = default)
{
string strategyName = this.GetType().Name;
logger ??= NullLogger.Instance;
using Activity? activity = CompactionTelemetry.ActivitySource.StartActivity(CompactionTelemetry.ActivityNames.Compact);
activity?.SetTag(CompactionTelemetry.Tags.Strategy, strategyName);
if (index.IncludedNonSystemGroupCount <= 1 || !this.Trigger(index))
{
activity?.SetTag(CompactionTelemetry.Tags.Triggered, false);
logger.LogCompactionSkipped(strategyName);
return false;
}
activity?.SetTag(CompactionTelemetry.Tags.Triggered, true);
int beforeTokens = index.IncludedTokenCount;
int beforeGroups = index.IncludedGroupCount;
int beforeMessages = index.IncludedMessageCount;
Stopwatch stopwatch = Stopwatch.StartNew();
bool compacted = await this.CompactCoreAsync(index, logger, cancellationToken).ConfigureAwait(false);
stopwatch.Stop();
activity?.SetTag(CompactionTelemetry.Tags.Compacted, compacted);
if (compacted)
{
activity?
.SetTag(CompactionTelemetry.Tags.BeforeTokens, beforeTokens)
.SetTag(CompactionTelemetry.Tags.AfterTokens, index.IncludedTokenCount)
.SetTag(CompactionTelemetry.Tags.BeforeMessages, beforeMessages)
.SetTag(CompactionTelemetry.Tags.AfterMessages, index.IncludedMessageCount)
.SetTag(CompactionTelemetry.Tags.BeforeGroups, beforeGroups)
.SetTag(CompactionTelemetry.Tags.AfterGroups, index.IncludedGroupCount)
.SetTag(CompactionTelemetry.Tags.DurationMs, stopwatch.ElapsedMilliseconds);
logger.LogCompactionCompleted(
strategyName,
stopwatch.ElapsedMilliseconds,
beforeMessages,
index.IncludedMessageCount,
beforeGroups,
index.IncludedGroupCount,
beforeTokens,
index.IncludedTokenCount);
}
return compacted;
}
/// <summary>
/// Ensures the provided value is not a negative number.
/// </summary>
/// <param name="value">The target value.</param>
/// <returns>0 if negative; otherwise the value</returns>
protected static int EnsureNonNegative(int value) => Math.Max(0, value);
}
@@ -0,0 +1,45 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics;
namespace Microsoft.Agents.AI.Compaction;
/// <summary>
/// Provides shared telemetry infrastructure for compaction operations.
/// </summary>
internal static class CompactionTelemetry
{
/// <summary>
/// The <see cref="ActivitySource"/> used to create activities for compaction operations.
/// </summary>
public static readonly ActivitySource ActivitySource = new(OpenTelemetryConsts.DefaultSourceName);
/// <summary>
/// Activity names used by compaction tracing.
/// </summary>
public static class ActivityNames
{
public const string Compact = "compaction.compact";
public const string CompactionProviderInvoke = "compaction.provider.invoke";
public const string Summarize = "compaction.summarize";
}
/// <summary>
/// Tag names used on compaction activities.
/// </summary>
public static class Tags
{
public const string Strategy = "compaction.strategy";
public const string Triggered = "compaction.triggered";
public const string Compacted = "compaction.compacted";
public const string BeforeTokens = "compaction.before.tokens";
public const string AfterTokens = "compaction.after.tokens";
public const string BeforeMessages = "compaction.before.messages";
public const string AfterMessages = "compaction.after.messages";
public const string BeforeGroups = "compaction.before.groups";
public const string AfterGroups = "compaction.after.groups";
public const string DurationMs = "compaction.duration_ms";
public const string GroupsSummarized = "compaction.groups_summarized";
public const string SummaryLength = "compaction.summary_length";
}
}
@@ -0,0 +1,15 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI.Compaction;
/// <summary>
/// Defines a condition based on <see cref="CompactionMessageIndex"/> metrics used by a <see cref="CompactionStrategy"/>
/// to determine when to trigger compaction and when the target compaction threshold has been met.
/// </summary>
/// <param name="index">An index over conversation messages that provides group, token, message, and turn metrics.</param>
/// <returns><see langword="true"/> to indicate the condition has been met; otherwise <see langword="false"/>.</returns>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public delegate bool CompactionTrigger(CompactionMessageIndex index);
@@ -0,0 +1,134 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI.Compaction;
/// <summary>
/// Factory to create <see cref="CompactionTrigger"/> predicates.
/// </summary>
/// <remarks>
/// <para>
/// A <see cref="CompactionTrigger"/> defines a condition based on <see cref="CompactionMessageIndex"/> metrics used
/// by a <see cref="CompactionStrategy"/> to determine when to trigger compaction and when the target
/// compaction threshold has been met.
/// </para>
/// <para>
/// Combine triggers with <see cref="All"/> or <see cref="Any"/> for compound conditions.
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public static class CompactionTriggers
{
/// <summary>
/// Always trigger, regardless of the message index state.
/// </summary>
public static readonly CompactionTrigger Always =
_ => true;
/// <summary>
/// Never trigger, regardless of the message index state.
/// </summary>
public static readonly CompactionTrigger Never =
_ => false;
/// <summary>
/// Creates a trigger that fires when the included token count is below the specified maximum.
/// </summary>
/// <param name="maxTokens">The token threshold.</param>
/// <returns>A <see cref="CompactionTrigger"/> that evaluates included token count.</returns>
public static CompactionTrigger TokensBelow(int maxTokens) =>
index => index.IncludedTokenCount < maxTokens;
/// <summary>
/// Creates a trigger that fires when the included token count exceeds the specified maximum.
/// </summary>
/// <param name="maxTokens">The token threshold.</param>
/// <returns>A <see cref="CompactionTrigger"/> that evaluates included token count.</returns>
public static CompactionTrigger TokensExceed(int maxTokens) =>
index => index.IncludedTokenCount > maxTokens;
/// <summary>
/// Creates a trigger that fires when the included message count exceeds the specified maximum.
/// </summary>
/// <param name="maxMessages">The message threshold.</param>
/// <returns>A <see cref="CompactionTrigger"/> that evaluates included message count.</returns>
public static CompactionTrigger MessagesExceed(int maxMessages) =>
index => index.IncludedMessageCount > maxMessages;
/// <summary>
/// Creates a trigger that fires when the included user turn count exceeds the specified maximum.
/// </summary>
/// <param name="maxTurns">The turn threshold.</param>
/// <returns>A <see cref="CompactionTrigger"/> that evaluates included turn count.</returns>
/// <remarks>
/// <para>
/// A user turn starts with a <see cref="CompactionGroupKind.User"/> group and includes all subsequent
/// non-user, non-system groups until the next user group or end of conversation. Each group is assigned
/// a <see cref="CompactionMessageGroup.TurnIndex"/> indicating which user turn it belongs to.
/// System messages (<see cref="CompactionGroupKind.System"/>) are always assigned a <see langword="null"/>
/// <see cref="CompactionMessageGroup.TurnIndex"/> since they never belong to a user turn.
/// </para>
/// <para>
/// The turn count is the number of distinct values defined by <see cref="CompactionMessageGroup.TurnIndex"/>.
/// </para>
/// </remarks>
public static CompactionTrigger TurnsExceed(int maxTurns) =>
index => index.IncludedTurnCount > maxTurns;
/// <summary>
/// Creates a trigger that fires when the included group count exceeds the specified maximum.
/// </summary>
/// <param name="maxGroups">The group threshold.</param>
/// <returns>A <see cref="CompactionTrigger"/> that evaluates included group count.</returns>
public static CompactionTrigger GroupsExceed(int maxGroups) =>
index => index.IncludedGroupCount > maxGroups;
/// <summary>
/// Creates a trigger that fires when the included message index contains at least one
/// non-excluded <see cref="CompactionGroupKind.ToolCall"/> group.
/// </summary>
/// <returns>A <see cref="CompactionTrigger"/> that evaluates included tool call presence.</returns>
public static CompactionTrigger HasToolCalls() =>
index => index.Groups.Any(g => !g.IsExcluded && g.Kind == CompactionGroupKind.ToolCall);
/// <summary>
/// Creates a compound trigger that fires only when <b>all</b> of the specified triggers fire.
/// </summary>
/// <param name="triggers">The triggers to combine with logical AND.</param>
/// <returns>A <see cref="CompactionTrigger"/> that requires all conditions to be met.</returns>
public static CompactionTrigger All(params CompactionTrigger[] triggers) =>
index =>
{
for (int i = 0; i < triggers.Length; i++)
{
if (!triggers[i](index))
{
return false;
}
}
return true;
};
/// <summary>
/// Creates a compound trigger that fires when <b>any</b> of the specified triggers fire.
/// </summary>
/// <param name="triggers">The triggers to combine with logical OR.</param>
/// <returns>A <see cref="CompactionTrigger"/> that requires at least one condition to be met.</returns>
public static CompactionTrigger Any(params CompactionTrigger[] triggers) =>
index =>
{
for (int i = 0; i < triggers.Length; i++)
{
if (triggers[i](index))
{
return true;
}
}
return false;
};
}
@@ -0,0 +1,62 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Compaction;
/// <summary>
/// A compaction strategy that executes a sequential pipeline of <see cref="CompactionStrategy"/> instances
/// against the same <see cref="CompactionMessageIndex"/>.
/// </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>
/// The pipeline itself always executes while each child strategy evaluates its own
/// <see cref="CompactionStrategy.Trigger"/> independently to decide whether it should compact.
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class PipelineCompactionStrategy : CompactionStrategy
{
/// <summary>
/// Initializes a new instance of the <see cref="PipelineCompactionStrategy"/> class.
/// </summary>
/// <param name="strategies">The ordered sequence of strategies to execute.</param>
public PipelineCompactionStrategy(params IEnumerable<CompactionStrategy> strategies)
: base(CompactionTriggers.Always)
{
this.Strategies = [.. Throw.IfNull(strategies)];
}
/// <summary>
/// Gets the ordered list of strategies in this pipeline.
/// </summary>
public IReadOnlyList<CompactionStrategy> Strategies { get; }
/// <inheritdoc/>
protected override async ValueTask<bool> CompactCoreAsync(CompactionMessageIndex index, ILogger logger, CancellationToken cancellationToken)
{
bool anyCompacted = false;
foreach (CompactionStrategy strategy in this.Strategies)
{
bool compacted = await strategy.CompactAsync(index, logger, cancellationToken).ConfigureAwait(false);
if (compacted)
{
anyCompacted = true;
}
}
return anyCompacted;
}
}
@@ -0,0 +1,140 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI.Compaction;
/// <summary>
/// A compaction strategy that removes the oldest user turns and their associated response groups
/// to bound conversation length.
/// </summary>
/// <remarks>
/// <para>
/// This strategy always preserves system messages. It identifies user turns in the
/// conversation (via <see cref="CompactionMessageGroup.TurnIndex"/>) and excludes the oldest turns
/// one at a time until the <see cref="CompactionStrategy.Target"/> condition is met.
/// </para>
/// <para>
/// <see cref="MinimumPreservedTurns"/> is a hard floor: even if the <see cref="CompactionStrategy.Target"/>
/// has not been reached, compaction will not touch the last <see cref="MinimumPreservedTurns"/> turns
/// (by <see cref="CompactionMessageGroup.TurnIndex"/>). Groups with a <see cref="CompactionMessageGroup.TurnIndex"/>
/// of <c>0</c> or <see langword="null"/> are always preserved regardless of this setting.
/// </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>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class SlidingWindowCompactionStrategy : CompactionStrategy
{
/// <summary>
/// The default minimum number of most-recent turns to preserve.
/// </summary>
public const int DefaultMinimumPreserved = 1;
/// <summary>
/// Initializes a new instance of the <see cref="SlidingWindowCompactionStrategy"/> class.
/// </summary>
/// <param name="trigger">
/// The <see cref="CompactionTrigger"/> that controls when compaction proceeds.
/// Use <see cref="CompactionTriggers.TurnsExceed"/> for turn-based thresholds.
/// </param>
/// <param name="minimumPreservedTurns">
/// The minimum number of most-recent turns (by <see cref="CompactionMessageGroup.TurnIndex"/>) to preserve.
/// This is a hard floor — compaction will not exclude turns within this range, regardless of the target condition.
/// Groups with <see cref="CompactionMessageGroup.TurnIndex"/> of <c>0</c> or <see langword="null"/> are always preserved.
/// </param>
/// <param name="target">
/// An optional target condition that controls when compaction stops. When <see langword="null"/>,
/// defaults to the inverse of the <paramref name="trigger"/> — compaction stops as soon as the trigger would no longer fire.
/// </param>
public SlidingWindowCompactionStrategy(CompactionTrigger trigger, int minimumPreservedTurns = DefaultMinimumPreserved, CompactionTrigger? target = null)
: base(trigger, target)
{
this.MinimumPreservedTurns = EnsureNonNegative(minimumPreservedTurns);
}
/// <summary>
/// Gets the minimum number of most-recent turns (by <see cref="CompactionMessageGroup.TurnIndex"/>) that are always preserved.
/// This is a hard floor that compaction cannot exceed, regardless of the target condition.
/// Groups with <see cref="CompactionMessageGroup.TurnIndex"/> of <c>0</c> or <see langword="null"/> are always preserved
/// independently of this value.
/// </summary>
public int MinimumPreservedTurns { get; }
/// <inheritdoc/>
protected override ValueTask<bool> CompactCoreAsync(CompactionMessageIndex index, ILogger logger, CancellationToken cancellationToken)
{
// Forward pass: pre-index non-system included groups by TurnIndex.
Dictionary<int, List<int>> turnGroups = [];
List<int> turnOrder = [];
for (int i = 0; i < index.Groups.Count; i++)
{
CompactionMessageGroup group = index.Groups[i];
if (!group.IsExcluded && group.Kind != CompactionGroupKind.System && group.TurnIndex is int turnIndex)
{
if (!turnGroups.TryGetValue(turnIndex, out List<int>? indices))
{
indices = [];
turnGroups[turnIndex] = indices;
turnOrder.Add(turnIndex);
}
indices.Add(i);
}
}
// Backward pass: identify protected turns by TurnIndex.
// TurnIndex = 0 is always protected (non-system messages before first user message).
// TurnIndex = null is always protected (system messages, already excluded from turn tracking).
HashSet<int> protectedTurnIndices = [];
if (turnGroups.ContainsKey(0))
{
protectedTurnIndices.Add(0);
}
// Protect the last MinimumPreservedTurns distinct turns.
int turnsToProtect = Math.Min(this.MinimumPreservedTurns, turnOrder.Count);
for (int i = turnOrder.Count - turnsToProtect; i < turnOrder.Count; i++)
{
protectedTurnIndices.Add(turnOrder[i]);
}
// Exclude turns oldest-first, skipping protected turns, checking target after each turn.
bool compacted = false;
for (int t = 0; t < turnOrder.Count; t++)
{
int currentTurnIndex = turnOrder[t];
if (protectedTurnIndices.Contains(currentTurnIndex))
{
continue;
}
List<int> groupIndices = turnGroups[currentTurnIndex];
for (int g = 0; g < groupIndices.Count; g++)
{
int idx = groupIndices[g];
index.Groups[idx].IsExcluded = true;
index.Groups[idx].ExcludeReason = $"Excluded by {nameof(SlidingWindowCompactionStrategy)}";
}
compacted = true;
if (this.Target(index))
{
break;
}
}
return new ValueTask<bool>(compacted);
}
}
@@ -0,0 +1,204 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging;
using Microsoft.Shared.DiagnosticIds;
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 single summary message that preserves key facts and context.
/// </summary>
/// <remarks>
/// <para>
/// This strategy protects system messages and the most recent <see cref="MinimumPreservedGroups"/>
/// 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
/// with <see cref="CompactionGroupKind.Summary"/>.
/// </para>
/// <para>
/// <see cref="MinimumPreservedGroups"/> is a hard floor: even if the <see cref="CompactionStrategy.Target"/>
/// has not been reached, compaction will not touch the last <see cref="MinimumPreservedGroups"/> non-system groups.
/// </para>
/// <para>
/// The <see cref="CompactionTrigger"/> predicate controls when compaction proceeds. Use
/// <see cref="CompactionTriggers"/> for common trigger conditions such as token thresholds.
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class SummarizationCompactionStrategy : CompactionStrategy
{
/// <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>
/// The default minimum number of most-recent non-system groups to preserve.
/// </summary>
public const int DefaultMinimumPreserved = 8;
/// <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="trigger">
/// The <see cref="CompactionTrigger"/> that controls when compaction proceeds.
/// </param>
/// <param name="minimumPreservedGroups">
/// The minimum number of most-recent non-system message groups to preserve.
/// This is a hard floor — compaction will not summarize groups beyond this limit,
/// regardless of the target condition. Defaults to 8, preserving the current and recent exchanges.
/// </param>
/// <param name="summarizationPrompt">
/// An optional custom system prompt for the summarization LLM call. When <see langword="null"/>,
/// <see cref="DefaultSummarizationPrompt"/> is used.
/// </param>
/// <param name="target">
/// An optional target condition that controls when compaction stops. When <see langword="null"/>,
/// defaults to the inverse of the <paramref name="trigger"/> — compaction stops as soon as the trigger would no longer fire.
/// </param>
public SummarizationCompactionStrategy(
IChatClient chatClient,
CompactionTrigger trigger,
int minimumPreservedGroups = DefaultMinimumPreserved,
string? summarizationPrompt = null,
CompactionTrigger? target = null)
: base(trigger, target)
{
this.ChatClient = Throw.IfNull(chatClient);
this.MinimumPreservedGroups = EnsureNonNegative(minimumPreservedGroups);
this.SummarizationPrompt = summarizationPrompt ?? DefaultSummarizationPrompt;
}
/// <summary>
/// Gets the chat client used for generating summaries.
/// </summary>
public IChatClient ChatClient { get; }
/// <summary>
/// Gets the minimum number of most-recent non-system groups that are always preserved.
/// This is a hard floor that compaction cannot exceed, regardless of the target condition.
/// </summary>
public int MinimumPreservedGroups { get; }
/// <summary>
/// Gets the prompt used when requesting summaries from the chat client.
/// </summary>
public string SummarizationPrompt { get; }
/// <inheritdoc/>
protected override async ValueTask<bool> CompactCoreAsync(CompactionMessageIndex index, ILogger logger, CancellationToken cancellationToken)
{
// Count non-system, non-excluded groups to determine which are protected
int nonSystemIncludedCount = 0;
for (int i = 0; i < index.Groups.Count; i++)
{
CompactionMessageGroup group = index.Groups[i];
if (!group.IsExcluded && group.Kind != CompactionGroupKind.System)
{
nonSystemIncludedCount++;
}
}
int protectedFromEnd = Math.Min(this.MinimumPreservedGroups, nonSystemIncludedCount);
int maxSummarizable = nonSystemIncludedCount - protectedFromEnd;
if (maxSummarizable <= 0)
{
return false;
}
// Mark oldest non-system groups for summarization one at a time until the target is met.
// Track which groups were excluded so we can restore them if the LLM call fails.
List<ChatMessage> summarizationMessages = [new ChatMessage(ChatRole.System, this.SummarizationPrompt)];
List<CompactionMessageGroup> excludedGroups = [];
int insertIndex = -1;
for (int i = 0; i < index.Groups.Count && excludedGroups.Count < maxSummarizable; i++)
{
CompactionMessageGroup group = index.Groups[i];
if (group.IsExcluded || group.Kind == CompactionGroupKind.System)
{
continue;
}
if (insertIndex < 0)
{
insertIndex = i;
}
// Collect messages from this group for summarization
summarizationMessages.AddRange(group.Messages);
group.IsExcluded = true;
group.ExcludeReason = $"Summarized by {nameof(SummarizationCompactionStrategy)}";
excludedGroups.Add(group);
// Stop marking when target condition is met
if (this.Target(index))
{
break;
}
}
// Generate summary using the chat client (single LLM call for all marked groups)
int summarized = excludedGroups.Count;
logger.LogSummarizationStarting(summarized, summarizationMessages.Count - 1, this.ChatClient.GetType().Name);
using Activity? summarizeActivity = CompactionTelemetry.ActivitySource.StartActivity(CompactionTelemetry.ActivityNames.Summarize);
summarizeActivity?.SetTag(CompactionTelemetry.Tags.GroupsSummarized, summarized);
ChatResponse response;
try
{
response = await this.ChatClient.GetResponseAsync(
summarizationMessages,
cancellationToken: cancellationToken).ConfigureAwait(false);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
// Restore excluded groups so the conversation is not left in an inconsistent state
for (int i = 0; i < excludedGroups.Count; i++)
{
excludedGroups[i].IsExcluded = false;
excludedGroups[i].ExcludeReason = null;
}
logger.LogSummarizationFailed(summarized, ex.Message);
return false;
}
string summaryText = string.IsNullOrWhiteSpace(response.Text) ? "[Summary unavailable]" : response.Text;
summarizeActivity?.SetTag(CompactionTelemetry.Tags.SummaryLength, summaryText.Length);
// Insert a summary group at the position of the first summarized group
ChatMessage summaryMessage = new(ChatRole.Assistant, $"[Summary]\n{summaryText}");
(summaryMessage.AdditionalProperties ??= [])[CompactionMessageGroup.SummaryPropertyKey] = true;
index.InsertGroup(insertIndex, CompactionGroupKind.Summary, [summaryMessage]);
logger.LogSummarizationCompleted(summaryText.Length, insertIndex);
return true;
}
}
@@ -0,0 +1,234 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI.Compaction;
/// <summary>
/// A compaction strategy that collapses old tool call groups into single concise assistant
/// messages, removing the detailed tool results while preserving a record of which tools were called
/// and what they returned.
/// </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="CompactionGroupKind.ToolCall"/>
/// groups outside the protected recent window, replacing each multi-message group
/// (assistant call + tool results) with a single assistant message in a YAML-like format:
/// <code>
/// [Tool Calls]
/// get_weather:
/// - Sunny and 72°F
/// search_docs:
/// - Found 3 docs
/// </code>
/// </para>
/// <para>
/// <see cref="MinimumPreservedGroups"/> is a hard floor: even if the <see cref="CompactionStrategy.Target"/>
/// has not been reached, compaction will not touch the last <see cref="MinimumPreservedGroups"/> non-system groups.
/// </para>
/// <para>
/// The <see cref="CompactionTrigger"/> predicate controls when compaction proceeds. Use
/// <see cref="CompactionTriggers"/> for common trigger conditions such as token thresholds.
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class ToolResultCompactionStrategy : CompactionStrategy
{
/// <summary>
/// The default minimum number of most-recent non-system groups to preserve.
/// </summary>
public const int DefaultMinimumPreserved = 16;
/// <summary>
/// Initializes a new instance of the <see cref="ToolResultCompactionStrategy"/> class.
/// </summary>
/// <param name="trigger">
/// The <see cref="CompactionTrigger"/> that controls when compaction proceeds.
/// </param>
/// <param name="minimumPreservedGroups">
/// The minimum number of most-recent non-system message groups to preserve.
/// This is a hard floor — compaction will not collapse groups beyond this limit,
/// regardless of the target condition.
/// Defaults to <see cref="DefaultMinimumPreserved"/>, ensuring the current turn's tool interactions remain visible.
/// </param>
/// <param name="target">
/// An optional target condition that controls when compaction stops. When <see langword="null"/>,
/// defaults to the inverse of the <paramref name="trigger"/> — compaction stops as soon as the trigger would no longer fire.
/// </param>
public ToolResultCompactionStrategy(CompactionTrigger trigger, int minimumPreservedGroups = DefaultMinimumPreserved, CompactionTrigger? target = null)
: base(trigger, target)
{
this.MinimumPreservedGroups = EnsureNonNegative(minimumPreservedGroups);
}
/// <summary>
/// Gets the minimum number of most-recent non-system groups that are always preserved.
/// This is a hard floor that compaction cannot exceed, regardless of the target condition.
/// </summary>
public int MinimumPreservedGroups { get; }
/// <inheritdoc/>
protected override ValueTask<bool> CompactCoreAsync(CompactionMessageIndex index, ILogger logger, CancellationToken cancellationToken)
{
// Identify protected groups: the N most-recent non-system, non-excluded groups
List<int> nonSystemIncludedIndices = [];
for (int i = 0; i < index.Groups.Count; i++)
{
CompactionMessageGroup group = index.Groups[i];
if (!group.IsExcluded && group.Kind != CompactionGroupKind.System)
{
nonSystemIncludedIndices.Add(i);
}
}
int protectedStart = EnsureNonNegative(nonSystemIncludedIndices.Count - this.MinimumPreservedGroups);
HashSet<int> protectedGroupIndices = [];
for (int i = protectedStart; i < nonSystemIncludedIndices.Count; i++)
{
protectedGroupIndices.Add(nonSystemIncludedIndices[i]);
}
// Collect eligible tool groups in order (oldest first)
List<int> eligibleIndices = [];
for (int i = 0; i < index.Groups.Count; i++)
{
CompactionMessageGroup group = index.Groups[i];
if (!group.IsExcluded && group.Kind == CompactionGroupKind.ToolCall && !protectedGroupIndices.Contains(i))
{
eligibleIndices.Add(i);
}
}
if (eligibleIndices.Count == 0)
{
return new ValueTask<bool>(false);
}
// Collapse one tool group at a time from oldest, re-checking target after each
bool compacted = false;
int offset = 0;
for (int e = 0; e < eligibleIndices.Count; e++)
{
int idx = eligibleIndices[e] + offset;
CompactionMessageGroup group = index.Groups[idx];
string summary = BuildToolCallSummary(group);
// Exclude the original group and insert a collapsed replacement
group.IsExcluded = true;
group.ExcludeReason = $"Collapsed by {nameof(ToolResultCompactionStrategy)}";
ChatMessage summaryMessage = new(ChatRole.Assistant, summary);
(summaryMessage.AdditionalProperties ??= [])[CompactionMessageGroup.SummaryPropertyKey] = true;
index.InsertGroup(idx + 1, CompactionGroupKind.Summary, [summaryMessage], group.TurnIndex);
offset++; // Each insertion shifts subsequent indices by 1
compacted = true;
// Stop when target condition is met
if (this.Target(index))
{
break;
}
}
return new ValueTask<bool>(compacted);
}
/// <summary>
/// Builds a concise summary string for a tool call group, including tool names,
/// results, and deduplication counts for repeated tool names.
/// </summary>
private static string BuildToolCallSummary(CompactionMessageGroup group)
{
// Collect function calls (callId, name) and results (callId → result text)
List<(string CallId, string Name)> functionCalls = [];
Dictionary<string, string> resultsByCallId = new();
List<string> plainTextResults = [];
foreach (ChatMessage message in group.Messages)
{
if (message.Contents is null)
{
continue;
}
bool hasFunctionResult = false;
foreach (AIContent content in message.Contents)
{
if (content is FunctionCallContent fcc)
{
functionCalls.Add((fcc.CallId, fcc.Name));
}
else if (content is FunctionResultContent frc && frc.CallId is not null)
{
resultsByCallId[frc.CallId] = frc.Result?.ToString() ?? string.Empty;
hasFunctionResult = true;
}
}
// Collect plain text from Tool-role messages that lack FunctionResultContent
if (!hasFunctionResult && message.Role == ChatRole.Tool && message.Text is string text)
{
plainTextResults.Add(text);
}
}
// Match function calls to their results using CallId or positional fallback,
// grouping by tool name while preserving first-seen order.
int plainTextIdx = 0;
List<string> orderedNames = [];
Dictionary<string, List<string>> groupedResults = new();
foreach ((string callId, string name) in functionCalls)
{
if (!groupedResults.TryGetValue(name, out _))
{
orderedNames.Add(name);
groupedResults[name] = [];
}
string? result = null;
if (resultsByCallId.TryGetValue(callId, out string? matchedResult))
{
result = matchedResult;
}
else if (plainTextIdx < plainTextResults.Count)
{
result = plainTextResults[plainTextIdx++];
}
if (!string.IsNullOrEmpty(result))
{
groupedResults[name].Add(result);
}
}
// Format as YAML-like block with [Tool Calls] header
List<string> lines = ["[Tool Calls]"];
foreach (string name in orderedNames)
{
List<string> results = groupedResults[name];
lines.Add($"{name}:");
if (results.Count > 0)
{
foreach (string result in results)
{
lines.Add($" - {result}");
}
}
}
return string.Join("\n", lines);
}
}
@@ -0,0 +1,110 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI.Compaction;
/// <summary>
/// A compaction strategy that removes the oldest non-system message groups,
/// keeping at least <see cref="MinimumPreservedGroups"/> most-recent groups intact.
/// </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>
/// <see cref="MinimumPreservedGroups"/> is a hard floor: even if the <see cref="CompactionStrategy.Target"/>
/// has not been reached, compaction will not touch the last <see cref="MinimumPreservedGroups"/> non-system groups.
/// </para>
/// <para>
/// The <see cref="CompactionTrigger"/> controls when compaction proceeds.
/// Use <see cref="CompactionTriggers"/> for common trigger conditions such as token or group thresholds.
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class TruncationCompactionStrategy : CompactionStrategy
{
/// <summary>
/// The default minimum number of most-recent non-system groups to preserve.
/// </summary>
public const int DefaultMinimumPreserved = 32;
/// <summary>
/// Initializes a new instance of the <see cref="TruncationCompactionStrategy"/> class.
/// </summary>
/// <param name="trigger">
/// The <see cref="CompactionTrigger"/> that controls when compaction proceeds.
/// </param>
/// <param name="minimumPreservedGroups">
/// The minimum number of most-recent non-system message groups to preserve.
/// This is a hard floor — compaction will not remove groups beyond this limit,
/// regardless of the target condition.
/// </param>
/// <param name="target">
/// An optional target condition that controls when compaction stops. When <see langword="null"/>,
/// defaults to the inverse of the <paramref name="trigger"/> — compaction stops as soon as the trigger would no longer fire.
/// </param>
public TruncationCompactionStrategy(CompactionTrigger trigger, int minimumPreservedGroups = DefaultMinimumPreserved, CompactionTrigger? target = null)
: base(trigger, target)
{
this.MinimumPreservedGroups = EnsureNonNegative(minimumPreservedGroups);
}
/// <summary>
/// Gets the minimum number of most-recent non-system message groups that are always preserved.
/// This is a hard floor that compaction cannot exceed, regardless of the target condition.
/// </summary>
public int MinimumPreservedGroups { get; }
/// <inheritdoc/>
protected override ValueTask<bool> CompactCoreAsync(CompactionMessageIndex index, ILogger logger, CancellationToken cancellationToken)
{
// Count removable (non-system, non-excluded) groups
int removableCount = 0;
for (int i = 0; i < index.Groups.Count; i++)
{
CompactionMessageGroup group = index.Groups[i];
if (!group.IsExcluded && group.Kind != CompactionGroupKind.System)
{
removableCount++;
}
}
int maxRemovable = removableCount - this.MinimumPreservedGroups;
if (maxRemovable <= 0)
{
return new ValueTask<bool>(false);
}
// Exclude oldest non-system groups one at a time, re-checking target after each
bool compacted = false;
int removed = 0;
for (int i = 0; i < index.Groups.Count && removed < maxRemovable; i++)
{
CompactionMessageGroup group = index.Groups[i];
if (group.IsExcluded || group.Kind == CompactionGroupKind.System)
{
continue;
}
group.IsExcluded = true;
group.ExcludeReason = $"Truncated by {nameof(TruncationCompactionStrategy)}";
removed++;
compacted = true;
// Stop when target condition is met
if (this.Target(index))
{
break;
}
}
return new ValueTask<bool>(compacted);
}
}
@@ -18,10 +18,14 @@
<ItemGroup>
<ProjectReference Include="..\Microsoft.Agents.AI.Abstractions\Microsoft.Agents.AI.Abstractions.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.AI" />
<PackageReference Include="Microsoft.Extensions.VectorData.Abstractions" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.ML.Tokenizers" />
<PackageReference Include="System.Diagnostics.DiagnosticSource" />
</ItemGroup>
@@ -36,7 +40,7 @@
<InternalsVisibleTo Include="Microsoft.Agents.AI.UnitTests" />
<InternalsVisibleTo Include="Microsoft.Agents.AI.Declarative.UnitTests" />
<InternalsVisibleTo Include="Microsoft.Agents.AI.Hosting.UnitTests"/>
<InternalsVisibleTo Include="Microsoft.Agents.AI.Hosting.UnitTests" />
</ItemGroup>
</Project>