mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Merge branch 'main' into feat/durable_task
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user