diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index 1b1e0daa08..516176ca76 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -108,6 +108,7 @@ + diff --git a/dotnet/agent-working-dotnet.slnx b/dotnet/agent-working-dotnet.slnx new file mode 100644 index 0000000000..651b185a23 --- /dev/null +++ b/dotnet/agent-working-dotnet.slnx @@ -0,0 +1,447 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/ChatHistoryProvider.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/ChatHistoryProvider.cs index ad3f3aacfb..cdd21e9e1c 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/ChatHistoryProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/ChatHistoryProvider.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; +using Microsoft.Agents.AI.Compaction; using Microsoft.Extensions.AI; using Microsoft.Shared.Diagnostics; @@ -269,6 +270,35 @@ public abstract class ChatHistoryProvider protected virtual ValueTask StoreChatHistoryAsync(InvokedContext context, CancellationToken cancellationToken = default) => default; + /// + /// Compacts the messages in place using the specified compaction strategy before they are stored. + /// + /// The messages to compact. This list is mutated in place. + /// The compaction strategy to apply. + /// The to monitor for cancellation requests. + /// A task representing the asynchronous operation. The task result is if compaction occurred. + /// + /// + /// This method organizes the messages into atomic units, + /// applies the compaction strategy, and replaces the contents of the list with the compacted result. + /// Tool call groups (assistant message + tool results) are treated as atomic units. + /// + /// + protected static async Task CompactMessagesAsync(List messages, ICompactionStrategy compactionStrategy, CancellationToken cancellationToken = default) + { + MessageGroups groups = MessageGroups.Create(messages); + + bool compacted = await compactionStrategy.CompactAsync(groups, cancellationToken).ConfigureAwait(false); + + if (compacted) + { + messages.Clear(); + messages.AddRange(groups.GetIncludedMessages()); + } + + return compacted; + } + /// Asks the for an object of the specified type . /// The type of object being requested. /// An optional key that can be used to help identify the target service. diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ICompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ICompactionStrategy.cs new file mode 100644 index 0000000000..615317062d --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ICompactionStrategy.cs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Agents.AI.Compaction; + +/// +/// Defines a strategy for compacting a to reduce context size. +/// +/// +/// +/// Compaction strategies operate on 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). +/// +/// +/// Strategies can be applied at three lifecycle points: +/// +/// In-run: During the tool loop, before each LLM call, to keep context within token limits. +/// Pre-write: Before persisting messages to storage via . +/// On existing storage: As a maintenance operation to compact stored history. +/// +/// +/// +/// Multiple strategies can be composed by applying them sequentially to the same . +/// +/// +public interface ICompactionStrategy +{ + /// + /// Compacts the specified message groups in place. + /// + /// The message group collection to compact. The strategy mutates this collection in place. + /// The to monitor for cancellation requests. + /// A task representing the asynchronous operation. The task result is if compaction occurred, otherwise. + Task CompactAsync(MessageGroups groups, CancellationToken cancellationToken = default); +} diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/MessageGroup.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/MessageGroup.cs new file mode 100644 index 0000000000..d443b15d87 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/MessageGroup.cs @@ -0,0 +1,108 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Compaction; + +/// +/// Represents a logical group of instances that must be kept or removed together during compaction. +/// +/// +/// +/// Message groups ensure atomic preservation of related messages. For example, an assistant message +/// containing tool calls and its corresponding tool result messages form a +/// group — removing one without the other would cause LLM API errors. +/// +/// +/// 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. +/// +/// +/// Each group tracks its , , and +/// so that can efficiently aggregate totals across all or only included groups. +/// These values are computed by and passed into the constructor. +/// +/// +public sealed class MessageGroup +{ + /// + /// The key used to identify a message as a compaction summary. + /// + /// + /// When this key is present with a value of , the message is classified as + /// by . + /// + public static readonly string SummaryPropertyKey = "_is_summary"; + + /// + /// Initializes a new instance of the class. + /// + /// The kind of message group. + /// The messages in this group. The list is captured as a read-only snapshot. + /// The total UTF-8 byte count of the text content in the messages. + /// The token count for the messages, computed by a tokenizer or estimated. + /// + /// The zero-based user turn this group belongs to, or for groups that precede + /// the first user message (e.g., system messages). + /// + public MessageGroup(MessageGroupKind kind, IReadOnlyList 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; + } + + /// + /// Gets the kind of this message group. + /// + public MessageGroupKind Kind { get; } + + /// + /// Gets the messages in this group. + /// + public IReadOnlyList Messages { get; } + + /// + /// Gets the number of messages in this group. + /// + public int MessageCount { get; } + + /// + /// Gets the total UTF-8 byte count of the text content in this group's messages. + /// + public int ByteCount { get; } + + /// + /// Gets the estimated or actual token count for this group's messages. + /// + public int TokenCount { get; } + + /// + /// Gets the zero-based user turn index this group belongs to, or + /// for groups that precede the first user message (e.g., system messages). + /// + /// + /// A turn starts with a group and includes all subsequent + /// non-user, non-system groups until the next user group or end of conversation. + /// + public int? TurnIndex { get; } + + /// + /// Gets or sets a value indicating whether this group is excluded from the projected message list. + /// + /// + /// Excluded groups are preserved in the collection for diagnostics or storage purposes + /// but are not included when calling . + /// + public bool IsExcluded { get; set; } + + /// + /// Gets or sets an optional reason explaining why this group was excluded. + /// + public string? ExcludeReason { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/MessageGroupKind.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/MessageGroupKind.cs new file mode 100644 index 0000000000..f3ee4c072f --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/MessageGroupKind.cs @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.Compaction; + +/// +/// Identifies the kind of a . +/// +/// +/// 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 group. +/// +public enum MessageGroupKind +{ + /// + /// A system message group containing one or more system messages. + /// + System, + + /// + /// A user message group containing a single user message. + /// + User, + + /// + /// An assistant message group containing a single assistant text response (no tool calls). + /// + AssistantText, + + /// + /// An atomic tool call group containing an assistant message with tool calls + /// followed by the corresponding tool result messages. + /// + /// + /// 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. + /// + ToolCall, + + /// + /// A summary message group produced by a compaction strategy (e.g., SummarizationCompactionStrategy). + /// + /// + /// Summary groups replace previously compacted messages with a condensed representation. + /// They are identified by the metadata entry + /// on the underlying . + /// + Summary, +} diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/MessageGroups.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/MessageGroups.cs new file mode 100644 index 0000000000..5661c1516a --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/MessageGroups.cs @@ -0,0 +1,312 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Microsoft.Extensions.AI; +using Microsoft.ML.Tokenizers; + +namespace Microsoft.Agents.AI.Compaction; + +/// +/// Represents a collection of instances derived from a flat list of objects. +/// +/// +/// +/// provides structural grouping of messages into logical units that +/// respect the atomic group preservation constraint: tool call assistant messages and their corresponding +/// tool result messages are always grouped together. +/// +/// +/// This collection supports exclusion-based projection, where groups can be marked as excluded +/// without being removed, allowing compaction strategies to toggle visibility while preserving +/// the full history for diagnostics or storage. +/// +/// +/// Each group tracks its own , , +/// and . The collection provides aggregate properties for both +/// the total (all groups) and included (non-excluded groups only) counts. +/// +/// +public sealed class MessageGroups +{ + /// + /// Gets the list of message groups in this collection. + /// + public IList Groups { get; } + + /// + /// Gets the tokenizer used for computing token counts, or if token counts are estimated. + /// + public Tokenizer? Tokenizer { get; } + + /// + /// Initializes a new instance of the class with the specified groups. + /// + /// The message groups. + /// An optional tokenizer retained for computing token counts when adding new groups. + public MessageGroups(IList groups, Tokenizer? tokenizer = null) + { + this.Groups = groups; + this.Tokenizer = tokenizer; + } + + /// + /// Creates a from a flat list of instances. + /// + /// The messages to group. + /// + /// An optional for computing token counts on each group. + /// When , token counts are estimated as ByteCount / 4. + /// + /// A new with messages organized into logical groups. + /// + /// The grouping algorithm: + /// + /// System messages become groups. + /// User messages become groups. + /// Assistant messages with tool calls, followed by their corresponding tool result messages, become groups. + /// Assistant messages marked with become groups. + /// Assistant messages without tool calls become groups. + /// + /// + public static MessageGroups Create(IList messages, Tokenizer? tokenizer = null) + { + List groups = []; + int index = 0; + int currentTurn = 0; + + while (index < messages.Count) + { + ChatMessage message = messages[index]; + + if (message.Role == ChatRole.System) + { + // System messages are not part of any turn + groups.Add(CreateGroup(MessageGroupKind.System, [message], tokenizer, turnIndex: null)); + index++; + } + else if (message.Role == ChatRole.User) + { + currentTurn++; + groups.Add(CreateGroup(MessageGroupKind.User, [message], tokenizer, currentTurn)); + index++; + } + else if (message.Role == ChatRole.Assistant && HasToolCalls(message)) + { + List groupMessages = [message]; + index++; + + // Collect all subsequent tool result messages + while (index < messages.Count && messages[index].Role == ChatRole.Tool) + { + groupMessages.Add(messages[index]); + index++; + } + + groups.Add(CreateGroup(MessageGroupKind.ToolCall, groupMessages, tokenizer, currentTurn)); + } + else if (message.Role == ChatRole.Assistant && IsSummaryMessage(message)) + { + groups.Add(CreateGroup(MessageGroupKind.Summary, [message], tokenizer, currentTurn)); + index++; + } + else + { + groups.Add(CreateGroup(MessageGroupKind.AssistantText, [message], tokenizer, currentTurn)); + index++; + } + } + + return new MessageGroups(groups, tokenizer); + } + + /// + /// Creates a new with byte and token counts computed using this collection's + /// , and adds it to the list at the specified index. + /// + /// The zero-based index at which the group should be inserted. + /// The kind of message group. + /// The messages in the group. + /// The optional turn index to assign to the new group. + /// The newly created . + public MessageGroup InsertGroup(int index, MessageGroupKind kind, IReadOnlyList messages, int? turnIndex = null) + { + MessageGroup group = CreateGroup(kind, messages, this.Tokenizer, turnIndex); + this.Groups.Insert(index, group); + return group; + } + + /// + /// Creates a new with byte and token counts computed using this collection's + /// , and appends it to the end of the list. + /// + /// The kind of message group. + /// The messages in the group. + /// The optional turn index to assign to the new group. + /// The newly created . + public MessageGroup AddGroup(MessageGroupKind kind, IReadOnlyList messages, int? turnIndex = null) + { + MessageGroup group = CreateGroup(kind, messages, this.Tokenizer, turnIndex); + this.Groups.Add(group); + return group; + } + + /// + /// Returns only the messages from groups that are not excluded. + /// + /// A list of instances from included groups, in order. + public IEnumerable GetIncludedMessages() => + this.Groups.Where(group => !group.IsExcluded).SelectMany(group => group.Messages); + + /// + /// Returns all messages from all groups, including excluded ones. + /// + /// A list of all instances, in order. + public IEnumerable GetAllMessages() => this.Groups.SelectMany(group => group.Messages); + + #region Total aggregates (all groups, including excluded) + + /// + /// Gets the total number of groups, including excluded ones. + /// + public int TotalGroupCount => this.Groups.Count; + + /// + /// Gets the total number of messages across all groups, including excluded ones. + /// + public int TotalMessageCount => this.Groups.Sum(g => g.MessageCount); + + /// + /// Gets the total UTF-8 byte count across all groups, including excluded ones. + /// + public int TotalByteCount => this.Groups.Sum(g => g.ByteCount); + + /// + /// Gets the total token count across all groups, including excluded ones. + /// + public int TotalTokenCount => this.Groups.Sum(g => g.TokenCount); + + #endregion + + #region Included aggregates (non-excluded groups only) + + /// + /// Gets the total number of groups that are not excluded. + /// + public int IncludedGroupCount => this.Groups.Count(g => !g.IsExcluded); + + /// + /// Gets the total number of messages across all included (non-excluded) groups. + /// + public int IncludedMessageCount => this.Groups.Where(g => !g.IsExcluded).Sum(g => g.MessageCount); + + /// + /// Gets the total UTF-8 byte count across all included (non-excluded) groups. + /// + public int IncludedByteCount => this.Groups.Where(g => !g.IsExcluded).Sum(g => g.ByteCount); + + /// + /// Gets the total token count across all included (non-excluded) groups. + /// + public int IncludedTokenCount => this.Groups.Where(g => !g.IsExcluded).Sum(g => g.TokenCount); + + #endregion + + #region Turn aggregates + + /// + /// Gets the total number of user turns across all groups (including those with excluded groups). + /// + public int TotalTurnCount => this.Groups.Select(group => group.TurnIndex).Distinct().Count(turnIndex => turnIndex is not null); + + /// + /// Gets the number of user turns that have at least one non-excluded group. + /// + public int IncludedTurnCount => this.Groups.Where(group => !group.IsExcluded).Select(group => group.TurnIndex).Distinct().Count(turnIndex => turnIndex is not null); + + /// + /// Returns all groups that belong to the specified user turn. + /// + /// The zero-based turn index. + /// The groups belonging to the turn, in order. + public IEnumerable GetTurnGroups(int turnIndex) => + this.Groups.Where(g => g.TurnIndex == turnIndex); + + #endregion + + /// + /// Computes the UTF-8 byte count for a set of messages. + /// + /// The messages to compute byte count for. + /// The total UTF-8 byte count of all message text content. + public static int ComputeByteCount(IReadOnlyList messages) + { + int total = 0; + for (int i = 0; i < messages.Count; i++) + { + string text = messages[i].Text ?? string.Empty; + if (text.Length > 0) + { + total += Encoding.UTF8.GetByteCount(text); + } + } + + return total; + } + + /// + /// Computes the token count for a set of messages using the specified tokenizer. + /// + /// The messages to compute token count for. + /// The tokenizer to use for counting tokens. + /// The total token count across all message text content. + public static int ComputeTokenCount(IReadOnlyList messages, Tokenizer tokenizer) + { + int total = 0; + for (int i = 0; i < messages.Count; i++) + { + string text = messages[i].Text ?? string.Empty; + if (text.Length > 0) + { + total += tokenizer.CountTokens(text); + } + } + + return total; + } + + private static MessageGroup CreateGroup(MessageGroupKind kind, IReadOnlyList messages, Tokenizer? tokenizer, int? turnIndex) + { + int byteCount = ComputeByteCount(messages); + int tokenCount = tokenizer is not null + ? ComputeTokenCount(messages, tokenizer) + : byteCount / 4; + + return new MessageGroup(kind, messages, byteCount, tokenCount, turnIndex); + } + + private static bool HasToolCalls(ChatMessage message) + { + if (message.Contents is null) + { + return false; + } + + foreach (AIContent content in message.Contents) + { + if (content is FunctionCallContent) + { + return true; + } + } + + return false; + } + + private static bool IsSummaryMessage(ChatMessage message) + { + return message.AdditionalProperties?.TryGetValue(MessageGroup.SummaryPropertyKey, out object? value) is true + && value is true; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/PipelineCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/PipelineCompactionStrategy.cs new file mode 100644 index 0000000000..0c4af67700 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/PipelineCompactionStrategy.cs @@ -0,0 +1,82 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Compaction; + +/// +/// A compaction strategy that executes a sequential pipeline of instances +/// against the same . +/// +/// +/// +/// 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. +/// +/// +/// When is and a is configured, +/// the pipeline stops executing after a strategy reduces the included group count to or below the target. +/// This avoids unnecessary work when an earlier strategy is sufficient. +/// +/// +public sealed class PipelineCompactionStrategy : ICompactionStrategy +{ + /// + /// Initializes a new instance of the class. + /// + /// The ordered sequence of strategies to execute. Must not be empty. + public PipelineCompactionStrategy(params IEnumerable strategies) + { + this.Strategies = [.. Throw.IfNull(strategies)]; + } + + /// + /// Gets the ordered list of strategies in this pipeline. + /// + public IReadOnlyList Strategies { get; } + + /// + /// Gets or sets a value indicating whether the pipeline should stop executing after a strategy + /// brings the included group count to or below . + /// + /// + /// Defaults to , meaning all strategies are always executed. + /// + public bool EarlyStop { get; set; } + + /// + /// Gets or sets the target number of included groups at which the pipeline stops + /// when is . + /// + /// + /// Defaults to , meaning early stop checks are not performed + /// even when is . + /// + public int? TargetIncludedGroupCount { get; set; } + + /// + public async Task CompactAsync(MessageGroups groups, CancellationToken cancellationToken = default) + { + bool anyCompacted = false; + + foreach (ICompactionStrategy strategy in this.Strategies) + { + bool compacted = await strategy.CompactAsync(groups, cancellationToken).ConfigureAwait(false); + + if (compacted) + { + anyCompacted = true; + } + + if (this.EarlyStop && this.TargetIncludedGroupCount is int targetIncludedGroupCount && groups.IncludedGroupCount <= targetIncludedGroupCount) + { + break; + } + } + + return anyCompacted; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/InMemoryChatHistoryProvider.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/InMemoryChatHistoryProvider.cs index 12e935b23e..4356c1ac3e 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/InMemoryChatHistoryProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/InMemoryChatHistoryProvider.cs @@ -6,6 +6,7 @@ using System.Linq; using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; +using Microsoft.Agents.AI.Compaction; using Microsoft.Extensions.AI; using Microsoft.Shared.Diagnostics; @@ -46,6 +47,7 @@ public sealed class InMemoryChatHistoryProvider : ChatHistoryProvider options?.JsonSerializerOptions); this.ChatReducer = options?.ChatReducer; this.ReducerTriggerEvent = options?.ReducerTriggerEvent ?? InMemoryChatHistoryProviderOptions.ChatReducerTriggerEvent.BeforeMessagesRetrieval; + this.CompactionStrategy = options?.CompactionStrategy; } /// @@ -61,6 +63,11 @@ public sealed class InMemoryChatHistoryProvider : ChatHistoryProvider /// public InMemoryChatHistoryProviderOptions.ChatReducerTriggerEvent ReducerTriggerEvent { get; } + /// + /// Gets the compaction strategy used to compact stored messages. If , no compaction is applied. + /// + public ICompactionStrategy? CompactionStrategy { get; } + /// /// Gets the chat messages stored for the specified session. /// @@ -109,6 +116,36 @@ public sealed class InMemoryChatHistoryProvider : ChatHistoryProvider { state.Messages = (await this.ChatReducer.ReduceAsync(state.Messages, cancellationToken).ConfigureAwait(false)).ToList(); } + + // Apply compaction strategy if configured (pre-write compaction) + if (this.CompactionStrategy is not null) + { + await CompactMessagesAsync(state.Messages, this.CompactionStrategy, cancellationToken).ConfigureAwait(false); + } + } + + /// + /// Compacts the stored messages for the specified session using the given or configured compaction strategy. + /// + /// The agent session whose stored messages should be compacted. + /// + /// An optional compaction strategy to use. If , the provider's configured + /// is used. If neither is available, an is thrown. + /// + /// The to monitor for cancellation requests. + /// A task representing the asynchronous operation. The task result is if compaction occurred. + /// No compaction strategy is configured or provided. + /// + /// This method enables on-demand compaction of stored history, for example as a maintenance operation. + /// It reads the full stored history, applies the compaction strategy, and writes the compacted result back. + /// + public async Task CompactStorageAsync(AgentSession? session, ICompactionStrategy? compactionStrategy = null, CancellationToken cancellationToken = default) + { + ICompactionStrategy strategy = compactionStrategy ?? this.CompactionStrategy + ?? throw new InvalidOperationException("No compaction strategy is configured or provided."); + + var state = this._sessionState.GetOrInitializeState(session); + return await CompactMessagesAsync(state.Messages, strategy, cancellationToken).ConfigureAwait(false); } /// diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/InMemoryChatHistoryProviderOptions.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/InMemoryChatHistoryProviderOptions.cs index ba24f55ded..b30968aa61 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/InMemoryChatHistoryProviderOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/InMemoryChatHistoryProviderOptions.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; using System.Text.Json; +using Microsoft.Agents.AI.Compaction; using Microsoft.Extensions.AI; namespace Microsoft.Agents.AI; @@ -73,6 +74,21 @@ public sealed class InMemoryChatHistoryProviderOptions /// public Func, IEnumerable>? ProvideOutputMessageFilter { get; set; } + /// + /// Gets or sets an optional to apply to stored messages after new messages are added. + /// + /// + /// + /// When set, this strategy is applied to the full stored message list after new messages have been appended. + /// This enables pre-write compaction to limit storage size. + /// + /// + /// The compaction strategy organizes messages into atomic groups (preserving tool-call/result pairings) + /// before applying the strategy logic. See for details. + /// + /// + public ICompactionStrategy? CompactionStrategy { get; set; } + /// /// Defines the events that can trigger a reducer in the . /// diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Microsoft.Agents.AI.Abstractions.csproj b/dotnet/src/Microsoft.Agents.AI.Abstractions/Microsoft.Agents.AI.Abstractions.csproj index e31093e174..b097386b14 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/Microsoft.Agents.AI.Abstractions.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Microsoft.Agents.AI.Abstractions.csproj @@ -29,6 +29,7 @@ + diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentOptions.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentOptions.cs index 38cad40bbe..4e1fe61f12 100644 --- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentOptions.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System.Collections.Generic; +using Microsoft.Agents.AI.Compaction; using Microsoft.Extensions.AI; namespace Microsoft.Agents.AI; @@ -45,6 +46,26 @@ public sealed class ChatClientAgentOptions /// public IEnumerable? AIContextProviders { get; set; } + /// + /// Gets or sets the to use for in-run context compaction. + /// + /// + /// + /// When set, this strategy is applied to the message list before each call to the underlying + /// during agent execution. This keeps the context within token limits + /// as tool calls accumulate during long-running agent invocations. + /// + /// + /// The strategy organizes messages into atomic groups (preserving tool-call/result pairings) + /// before applying compaction logic. See for details. + /// + /// + /// This is separate from the compaction strategy on , + /// which applies pre-write compaction before storing messages. Both can be used together. + /// + /// + public ICompactionStrategy? CompactionStrategy { get; set; } + /// /// Gets or sets a value indicating whether to use the provided instance as is, /// without applying any default decorators. @@ -101,6 +122,7 @@ public sealed class ChatClientAgentOptions ChatOptions = this.ChatOptions?.Clone(), ChatHistoryProvider = this.ChatHistoryProvider, AIContextProviders = this.AIContextProviders is null ? null : new List(this.AIContextProviders), + CompactionStrategy = this.CompactionStrategy, UseProvidedChatClientAsIs = this.UseProvidedChatClientAsIs, ClearOnChatHistoryProviderConflict = this.ClearOnChatHistoryProviderConflict, WarnOnChatHistoryProviderConflict = this.WarnOnChatHistoryProviderConflict, diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientExtensions.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientExtensions.cs index 653f198402..8b83830afc 100644 --- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientExtensions.cs @@ -53,9 +53,16 @@ public static class ChatClientExtensions { var chatBuilder = chatClient.AsBuilder(); + // Add compaction as the innermost middleware so it runs before every LLM call, + // including those triggered by tool call iterations within FunctionInvokingChatClient. + if (options?.CompactionStrategy is { } compactionStrategy) + { + chatBuilder.Use(innerClient => new CompactingChatClient(innerClient, compactionStrategy)); + } + if (chatClient.GetService() is null) { - _ = chatBuilder.Use((innerClient, services) => + chatBuilder.Use((innerClient, services) => { var loggerFactory = services.GetService(); diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/CompactingChatClient.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/CompactingChatClient.cs new file mode 100644 index 0000000000..8e94b840ea --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/CompactingChatClient.cs @@ -0,0 +1,75 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Compaction; +using Microsoft.Extensions.AI; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// +/// A delegating that applies an to the message list +/// before each call to the inner chat client. +/// +/// +/// +/// This client is used for in-run compaction during the tool loop. It is inserted into the +/// pipeline before the so that +/// compaction is applied before every LLM call, including those triggered by tool call iterations. +/// +/// +/// The compaction strategy organizes messages into atomic groups (preserving tool-call/result pairings) +/// before applying compaction logic. Only included messages are forwarded to the inner client. +/// +/// +internal sealed class CompactingChatClient : DelegatingChatClient +{ + private readonly ICompactionStrategy _compactionStrategy; + + /// + /// Initializes a new instance of the class. + /// + /// The inner chat client to delegate to. + /// The compaction strategy to apply before each call. + public CompactingChatClient(IChatClient innerClient, ICompactionStrategy compactionStrategy) + : base(innerClient) + { + this._compactionStrategy = Throw.IfNull(compactionStrategy); + } + + /// + public override async Task GetResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + CancellationToken cancellationToken = default) + { + List compactedMessages = await this.ApplyCompactionAsync(messages, cancellationToken).ConfigureAwait(false); + return await base.GetResponseAsync(compactedMessages, options, cancellationToken).ConfigureAwait(false); + } + + /// + public override async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + List compactedMessages = await this.ApplyCompactionAsync(messages, cancellationToken).ConfigureAwait(false); + await foreach (var update in base.GetStreamingResponseAsync(compactedMessages, options, cancellationToken).ConfigureAwait(false)) + { + yield return update; + } + } + + private async Task> ApplyCompactionAsync(IEnumerable messages, CancellationToken cancellationToken) + { + List messageList = messages as List ?? [.. messages]; + MessageGroups groups = MessageGroups.Create(messageList); + + bool compacted = await this._compactionStrategy.CompactAsync(groups, cancellationToken).ConfigureAwait(false); + + return compacted ? [.. groups.GetIncludedMessages()] : messageList; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/SummarizationCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/SummarizationCompactionStrategy.cs new file mode 100644 index 0000000000..3255d3f998 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/SummarizationCompactionStrategy.cs @@ -0,0 +1,135 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Compaction; + +/// +/// A compaction strategy that summarizes older message groups using an , +/// replacing them with a single summary message. +/// +/// +/// +/// When the number of included message groups exceeds , +/// this strategy extracts the oldest non-system groups (up to the threshold), sends them +/// to an for summarization, and replaces those groups with a single +/// assistant message containing the summary. +/// +/// +/// System message groups are always preserved and never included in summarization. +/// +/// +public sealed class SummarizationCompactionStrategy : ICompactionStrategy +{ + private const string DefaultSummarizationPrompt = + "Summarize the following conversation concisely, preserving key facts, decisions, and context. " + + "Focus on information that would be needed to continue the conversation effectively."; + + /// + /// Initializes a new instance of the class. + /// + /// The chat client to use for generating summaries. + /// The maximum number of included groups allowed before summarization is triggered. + /// Optional custom prompt for the summarization request. If , a default prompt is used. + public SummarizationCompactionStrategy(IChatClient chatClient, int maxGroupsBeforeSummary, string? summarizationPrompt = null) + { + this.ChatClient = Throw.IfNull(chatClient); + this.MaxGroupsBeforeSummary = maxGroupsBeforeSummary; + this.SummarizationPrompt = summarizationPrompt ?? DefaultSummarizationPrompt; + } + + /// + /// Gets the chat client used for generating summaries. + /// + public IChatClient ChatClient { get; } + + /// + /// Gets the maximum number of included groups allowed before summarization is triggered. + /// + public int MaxGroupsBeforeSummary { get; } + + /// + /// Gets the prompt used when requesting summaries from the chat client. + /// + public string SummarizationPrompt { get; } + + /// + public async Task CompactAsync(MessageGroups groups, CancellationToken cancellationToken = default) + { + int includedCount = groups.IncludedGroupCount; + if (includedCount <= this.MaxGroupsBeforeSummary) + { + return false; + } + + // Determine how many groups to summarize (keep the most recent MaxGroupsBeforeSummary groups) + int groupsToSummarize = includedCount - this.MaxGroupsBeforeSummary; + + // Collect the oldest non-system included groups for summarization + StringBuilder conversationText = new(); + int summarized = 0; + int insertIndex = -1; + + for (int i = 0; i < groups.Groups.Count && summarized < groupsToSummarize; i++) + { + MessageGroup group = groups.Groups[i]; + if (group.IsExcluded || group.Kind == MessageGroupKind.System) + { + continue; + } + + if (insertIndex < 0) + { + insertIndex = i; + } + + // Build text representation of the group for summarization + foreach (ChatMessage message in group.Messages) + { + string text = message.Text ?? string.Empty; + if (!string.IsNullOrEmpty(text)) + { + conversationText.AppendLine($"{message.Role}: {text}"); + } + } + + group.IsExcluded = true; + group.ExcludeReason = "Summarized by SummarizationCompactionStrategy"; + summarized++; + } + + if (summarized == 0) + { + return false; + } + + // Generate summary using the chat client + ChatResponse response = await this.ChatClient.GetResponseAsync( + [ + new ChatMessage(ChatRole.System, this.SummarizationPrompt), + new ChatMessage(ChatRole.User, conversationText.ToString()), + ], + cancellationToken: cancellationToken).ConfigureAwait(false); + + string summaryText = response.Text ?? string.Empty; + + // Insert a summary group at the position of the first summarized group + ChatMessage summaryMessage = new(ChatRole.Assistant, $"[Summary of earlier conversation]: {summaryText}"); + (summaryMessage.AdditionalProperties ??= [])[MessageGroup.SummaryPropertyKey] = true; + + if (insertIndex >= 0) + { + groups.InsertGroup(insertIndex, MessageGroupKind.Summary, [summaryMessage]); + } + else + { + groups.AddGroup(MessageGroupKind.Summary, [summaryMessage]); + } + + return true; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/TruncationCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/TruncationCompactionStrategy.cs new file mode 100644 index 0000000000..dbeef544cd --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/TruncationCompactionStrategy.cs @@ -0,0 +1,80 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Agents.AI.Compaction; + +/// +/// A compaction strategy that keeps the most recent message groups up to a specified limit, +/// optionally preserving system message groups. +/// +/// +/// +/// This strategy implements a sliding window approach: it marks older groups as excluded +/// while keeping the most recent groups within the configured limit. +/// System message groups can optionally be preserved regardless of their position. +/// +/// +/// This strategy respects atomic group preservation — tool call groups (assistant message + tool results) +/// are always kept or excluded together. +/// +/// +public sealed class TruncationCompactionStrategy : ICompactionStrategy +{ + /// + /// Initializes a new instance of the class. + /// + /// The maximum number of message groups to keep. Must be greater than zero. + /// Whether to preserve system message groups regardless of position. Defaults to . + public TruncationCompactionStrategy(int maxGroups, bool preserveSystemMessages = true) + { + this.MaxGroups = maxGroups; + this.PreserveSystemMessages = preserveSystemMessages; + } + + /// + /// Gets the maximum number of message groups to retain after compaction. + /// + public int MaxGroups { get; } + + /// + /// Gets a value indicating whether system message groups are preserved regardless of their position in the conversation. + /// + public bool PreserveSystemMessages { get; } + + /// + public Task CompactAsync(MessageGroups groups, CancellationToken cancellationToken = default) + { + int includedCount = groups.IncludedGroupCount; + if (includedCount <= this.MaxGroups) + { + return Task.FromResult(false); + } + + int excessCount = includedCount - this.MaxGroups; + bool compacted = false; + + // Exclude oldest non-system groups first (iterate from the beginning) + for (int i = 0; i < groups.Groups.Count && excessCount > 0; i++) + { + MessageGroup group = groups.Groups[i]; + if (group.IsExcluded) + { + continue; + } + + if (this.PreserveSystemMessages && group.Kind == MessageGroupKind.System) + { + continue; + } + + group.IsExcluded = true; + group.ExcludeReason = "Truncated by TruncationCompactionStrategy"; + excessCount--; + compacted = true; + } + + return Task.FromResult(compacted); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/InMemoryChatHistoryProviderCompactionTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/InMemoryChatHistoryProviderCompactionTests.cs new file mode 100644 index 0000000000..ae02b85779 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/InMemoryChatHistoryProviderCompactionTests.cs @@ -0,0 +1,268 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Compaction; +using Microsoft.Extensions.AI; +using Moq; + +namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction; + +/// +/// Contains tests for the compaction integration with . +/// +public class InMemoryChatHistoryProviderCompactionTests +{ + private static readonly AIAgent s_mockAgent = new Mock().Object; + + private static AgentSession CreateMockSession() => new Mock().Object; + + [Fact] + public void Constructor_SetsCompactionStrategy_FromOptions() + { + // Arrange + Mock strategy = new(); + + // Act + InMemoryChatHistoryProvider provider = new(new InMemoryChatHistoryProviderOptions + { + CompactionStrategy = strategy.Object, + }); + + // Assert + Assert.Same(strategy.Object, provider.CompactionStrategy); + } + + [Fact] + public void Constructor_CompactionStrategyIsNull_ByDefault() + { + // Arrange & Act + InMemoryChatHistoryProvider provider = new(); + + // Assert + Assert.Null(provider.CompactionStrategy); + } + + [Fact] + public async Task StoreChatHistoryAsync_AppliesCompaction_WhenStrategyConfiguredAsync() + { + // Arrange — mock strategy that excludes the first included non-system group + Mock mockStrategy = new(); + mockStrategy.Setup(s => s.CompactAsync(It.IsAny(), It.IsAny())) + .Callback((groups, _) => + { + foreach (MessageGroup group in groups.Groups) + { + if (!group.IsExcluded && group.Kind != MessageGroupKind.System) + { + group.IsExcluded = true; + group.ExcludeReason = "Mock compaction"; + break; + } + } + }) + .ReturnsAsync(true); + + InMemoryChatHistoryProvider provider = new(new InMemoryChatHistoryProviderOptions + { + CompactionStrategy = mockStrategy.Object, + }); + + AgentSession session = CreateMockSession(); + + // Pre-populate with some messages + List existingMessages = + [ + new ChatMessage(ChatRole.User, "First"), + new ChatMessage(ChatRole.Assistant, "Response 1"), + ]; + provider.SetMessages(session, existingMessages); + + // Invoke the store flow with additional messages + List requestMessages = + [ + new ChatMessage(ChatRole.User, "Second"), + ]; + List responseMessages = + [ + new ChatMessage(ChatRole.Assistant, "Response 2"), + ]; + + ChatHistoryProvider.InvokedContext context = new(s_mockAgent, session, requestMessages, responseMessages); + + // Act + await provider.InvokedAsync(context); + + // Assert - compaction should have removed one group + List storedMessages = provider.GetMessages(session); + Assert.Equal(3, storedMessages.Count); + mockStrategy.Verify(s => s.CompactAsync(It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task StoreChatHistoryAsync_DoesNotCompact_WhenNoStrategyAsync() + { + // Arrange + InMemoryChatHistoryProvider provider = new(); + AgentSession session = CreateMockSession(); + + List requestMessages = + [ + new ChatMessage(ChatRole.User, "Hello"), + ]; + List responseMessages = + [ + new ChatMessage(ChatRole.Assistant, "Hi!"), + ]; + + ChatHistoryProvider.InvokedContext context = new(s_mockAgent, session, requestMessages, responseMessages); + + // Act + await provider.InvokedAsync(context); + + // Assert - all messages should be stored + List storedMessages = provider.GetMessages(session); + Assert.Equal(2, storedMessages.Count); + } + + [Fact] + public async Task CompactStorageAsync_CompactsStoredMessagesAsync() + { + // Arrange — mock strategy that excludes the two oldest non-system groups + Mock mockStrategy = new(); + mockStrategy.Setup(s => s.CompactAsync(It.IsAny(), It.IsAny())) + .Callback((groups, _) => + { + int excluded = 0; + foreach (MessageGroup group in groups.Groups) + { + if (!group.IsExcluded && group.Kind != MessageGroupKind.System && excluded < 2) + { + group.IsExcluded = true; + excluded++; + } + } + }) + .ReturnsAsync(true); + + InMemoryChatHistoryProvider provider = new(new InMemoryChatHistoryProviderOptions + { + CompactionStrategy = mockStrategy.Object, + }); + + AgentSession session = CreateMockSession(); + provider.SetMessages(session, + [ + new ChatMessage(ChatRole.User, "First"), + new ChatMessage(ChatRole.Assistant, "Response 1"), + new ChatMessage(ChatRole.User, "Second"), + new ChatMessage(ChatRole.Assistant, "Response 2"), + ]); + + // Act + bool result = await provider.CompactStorageAsync(session); + + // Assert + Assert.True(result); + List messages = provider.GetMessages(session); + Assert.Equal(2, messages.Count); + mockStrategy.Verify(s => s.CompactAsync(It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task CompactStorageAsync_UsesProvidedStrategy_OverDefaultAsync() + { + // Arrange + Mock defaultStrategy = new(); + Mock overrideStrategy = new(); + + overrideStrategy.Setup(s => s.CompactAsync(It.IsAny(), It.IsAny())) + .Callback((groups, _) => + { + // Exclude all but the last group + for (int i = 0; i < groups.Groups.Count - 1; i++) + { + groups.Groups[i].IsExcluded = true; + } + }) + .ReturnsAsync(true); + + InMemoryChatHistoryProvider provider = new(new InMemoryChatHistoryProviderOptions + { + CompactionStrategy = defaultStrategy.Object, + }); + + AgentSession session = CreateMockSession(); + provider.SetMessages(session, + [ + new ChatMessage(ChatRole.User, "First"), + new ChatMessage(ChatRole.User, "Second"), + new ChatMessage(ChatRole.User, "Third"), + ]); + + // Act + bool result = await provider.CompactStorageAsync(session, overrideStrategy.Object); + + // Assert + Assert.True(result); + List messages = provider.GetMessages(session); + Assert.Single(messages); + Assert.Equal("Third", messages[0].Text); + + // Verify the override was used, not the default + overrideStrategy.Verify(s => s.CompactAsync(It.IsAny(), It.IsAny()), Times.Once); + defaultStrategy.Verify(s => s.CompactAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task CompactStorageAsync_Throws_WhenNoStrategyAvailableAsync() + { + // Arrange + InMemoryChatHistoryProvider provider = new(); + AgentSession session = CreateMockSession(); + + // Act & Assert + await Assert.ThrowsAsync( + () => provider.CompactStorageAsync(session)); + } + + [Fact] + public async Task CompactStorageAsync_WithCustomStrategy_AppliesCustomLogicAsync() + { + // Arrange + Mock mockStrategy = new(); + mockStrategy.Setup(s => s.CompactAsync(It.IsAny(), It.IsAny())) + .Callback((groups, _) => + { + // Exclude all user groups + foreach (MessageGroup group in groups.Groups) + { + if (group.Kind == MessageGroupKind.User) + { + group.IsExcluded = true; + } + } + }) + .ReturnsAsync(true); + + InMemoryChatHistoryProvider provider = new(); + AgentSession session = CreateMockSession(); + provider.SetMessages(session, + [ + new ChatMessage(ChatRole.System, "System"), + new ChatMessage(ChatRole.User, "User message"), + new ChatMessage(ChatRole.Assistant, "Response"), + ]); + + // Act + bool result = await provider.CompactStorageAsync(session, mockStrategy.Object); + + // Assert + Assert.True(result); + List messages = provider.GetMessages(session); + Assert.Equal(2, messages.Count); + Assert.Equal(ChatRole.System, messages[0].Role); + Assert.Equal(ChatRole.Assistant, messages[1].Role); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/MessageGroupsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/MessageGroupsTests.cs new file mode 100644 index 0000000000..c3dfedd208 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/MessageGroupsTests.cs @@ -0,0 +1,524 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using Microsoft.Agents.AI.Compaction; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction; + +/// +/// Contains tests for the class. +/// +public class MessageGroupsTests +{ + [Fact] + public void Create_EmptyList_ReturnsEmptyGroups() + { + // Arrange + List messages = []; + + // Act + MessageGroups groups = MessageGroups.Create(messages); + + // Assert + Assert.Empty(groups.Groups); + } + + [Fact] + public void Create_SystemMessage_CreatesSystemGroup() + { + // Arrange + List messages = + [ + new ChatMessage(ChatRole.System, "You are helpful."), + ]; + + // Act + MessageGroups groups = MessageGroups.Create(messages); + + // Assert + Assert.Single(groups.Groups); + Assert.Equal(MessageGroupKind.System, groups.Groups[0].Kind); + Assert.Single(groups.Groups[0].Messages); + } + + [Fact] + public void Create_UserMessage_CreatesUserGroup() + { + // Arrange + List messages = + [ + new ChatMessage(ChatRole.User, "Hello"), + ]; + + // Act + MessageGroups groups = MessageGroups.Create(messages); + + // Assert + Assert.Single(groups.Groups); + Assert.Equal(MessageGroupKind.User, groups.Groups[0].Kind); + } + + [Fact] + public void Create_AssistantTextMessage_CreatesAssistantTextGroup() + { + // Arrange + List messages = + [ + new ChatMessage(ChatRole.Assistant, "Hi there!"), + ]; + + // Act + MessageGroups groups = MessageGroups.Create(messages); + + // Assert + Assert.Single(groups.Groups); + Assert.Equal(MessageGroupKind.AssistantText, groups.Groups[0].Kind); + } + + [Fact] + public void Create_ToolCallWithResults_CreatesAtomicToolCallGroup() + { + // Arrange + ChatMessage assistantMessage = new(ChatRole.Assistant, [new FunctionCallContent("call1", "get_weather", new Dictionary { ["city"] = "Seattle" })]); + ChatMessage toolResult = new(ChatRole.Tool, "Sunny, 72°F"); + + List messages = [assistantMessage, toolResult]; + + // Act + MessageGroups groups = MessageGroups.Create(messages); + + // Assert + Assert.Single(groups.Groups); + Assert.Equal(MessageGroupKind.ToolCall, groups.Groups[0].Kind); + Assert.Equal(2, groups.Groups[0].Messages.Count); + Assert.Same(assistantMessage, groups.Groups[0].Messages[0]); + Assert.Same(toolResult, groups.Groups[0].Messages[1]); + } + + [Fact] + public void Create_MixedConversation_GroupsCorrectly() + { + // Arrange + ChatMessage systemMsg = new(ChatRole.System, "You are helpful."); + ChatMessage userMsg = new(ChatRole.User, "What's the weather?"); + ChatMessage assistantToolCall = new(ChatRole.Assistant, [new FunctionCallContent("call1", "get_weather")]); + ChatMessage toolResult = new(ChatRole.Tool, "Sunny"); + ChatMessage assistantText = new(ChatRole.Assistant, "The weather is sunny!"); + + List messages = [systemMsg, userMsg, assistantToolCall, toolResult, assistantText]; + + // Act + MessageGroups groups = MessageGroups.Create(messages); + + // Assert + Assert.Equal(4, groups.Groups.Count); + Assert.Equal(MessageGroupKind.System, groups.Groups[0].Kind); + Assert.Equal(MessageGroupKind.User, groups.Groups[1].Kind); + Assert.Equal(MessageGroupKind.ToolCall, groups.Groups[2].Kind); + Assert.Equal(2, groups.Groups[2].Messages.Count); + Assert.Equal(MessageGroupKind.AssistantText, groups.Groups[3].Kind); + } + + [Fact] + public void Create_MultipleToolResults_GroupsAllWithAssistant() + { + // Arrange + ChatMessage assistantToolCall = new(ChatRole.Assistant, [ + new FunctionCallContent("call1", "get_weather"), + new FunctionCallContent("call2", "get_time"), + ]); + ChatMessage toolResult1 = new(ChatRole.Tool, "Sunny"); + ChatMessage toolResult2 = new(ChatRole.Tool, "3:00 PM"); + + List messages = [assistantToolCall, toolResult1, toolResult2]; + + // Act + MessageGroups groups = MessageGroups.Create(messages); + + // Assert + Assert.Single(groups.Groups); + Assert.Equal(MessageGroupKind.ToolCall, groups.Groups[0].Kind); + Assert.Equal(3, groups.Groups[0].Messages.Count); + } + + [Fact] + public void GetIncludedMessages_ExcludesMarkedGroups() + { + // Arrange + ChatMessage msg1 = new(ChatRole.User, "First"); + ChatMessage msg2 = new(ChatRole.Assistant, "Response"); + ChatMessage msg3 = new(ChatRole.User, "Second"); + + MessageGroups groups = MessageGroups.Create([msg1, msg2, msg3]); + groups.Groups[1].IsExcluded = true; + + // Act + List included = [.. groups.GetIncludedMessages()]; + + // Assert + Assert.Equal(2, included.Count); + Assert.Same(msg1, included[0]); + Assert.Same(msg3, included[1]); + } + + [Fact] + public void GetAllMessages_IncludesExcludedGroups() + { + // Arrange + ChatMessage msg1 = new(ChatRole.User, "First"); + ChatMessage msg2 = new(ChatRole.Assistant, "Response"); + + MessageGroups groups = MessageGroups.Create([msg1, msg2]); + groups.Groups[0].IsExcluded = true; + + // Act + List all = [.. groups.GetAllMessages()]; + + // Assert + Assert.Equal(2, all.Count); + } + + [Fact] + public void IncludedGroupCount_ReflectsExclusions() + { + // Arrange + MessageGroups groups = MessageGroups.Create( + [ + new ChatMessage(ChatRole.User, "A"), + new ChatMessage(ChatRole.Assistant, "B"), + new ChatMessage(ChatRole.User, "C"), + ]); + + groups.Groups[1].IsExcluded = true; + + // Act & Assert + Assert.Equal(2, groups.IncludedGroupCount); + Assert.Equal(2, groups.IncludedMessageCount); + } + + [Fact] + public void Create_SummaryMessage_CreatesSummaryGroup() + { + // Arrange + ChatMessage summaryMessage = new(ChatRole.Assistant, "[Summary of earlier conversation]: key facts..."); + (summaryMessage.AdditionalProperties ??= [])[MessageGroup.SummaryPropertyKey] = true; + + List messages = [summaryMessage]; + + // Act + MessageGroups groups = MessageGroups.Create(messages); + + // Assert + Assert.Single(groups.Groups); + Assert.Equal(MessageGroupKind.Summary, groups.Groups[0].Kind); + Assert.Same(summaryMessage, groups.Groups[0].Messages[0]); + } + + [Fact] + public void Create_SummaryAmongOtherMessages_GroupsCorrectly() + { + // Arrange + ChatMessage systemMsg = new(ChatRole.System, "You are helpful."); + ChatMessage summaryMsg = new(ChatRole.Assistant, "[Summary]: previous context"); + (summaryMsg.AdditionalProperties ??= [])[MessageGroup.SummaryPropertyKey] = true; + ChatMessage userMsg = new(ChatRole.User, "Continue..."); + + List messages = [systemMsg, summaryMsg, userMsg]; + + // Act + MessageGroups groups = MessageGroups.Create(messages); + + // Assert + Assert.Equal(3, groups.Groups.Count); + Assert.Equal(MessageGroupKind.System, groups.Groups[0].Kind); + Assert.Equal(MessageGroupKind.Summary, groups.Groups[1].Kind); + Assert.Equal(MessageGroupKind.User, groups.Groups[2].Kind); + } + + [Fact] + public void MessageGroup_StoresPassedCounts() + { + // Arrange & Act + MessageGroup group = new(MessageGroupKind.User, [new ChatMessage(ChatRole.User, "Hello")], byteCount: 5, tokenCount: 2); + + // Assert + Assert.Equal(1, group.MessageCount); + Assert.Equal(5, group.ByteCount); + Assert.Equal(2, group.TokenCount); + } + + [Fact] + public void MessageGroup_MessagesAreImmutable() + { + // Arrange + IReadOnlyList messages = [new ChatMessage(ChatRole.User, "Hello")]; + MessageGroup group = new(MessageGroupKind.User, messages, byteCount: 5, tokenCount: 1); + + // Assert — Messages is IReadOnlyList, not IList + Assert.IsAssignableFrom>(group.Messages); + Assert.Same(messages, group.Messages); + } + + [Fact] + public void Create_ComputesByteCount_Utf8() + { + // Arrange — "Hello" is 5 UTF-8 bytes + MessageGroups groups = MessageGroups.Create([new ChatMessage(ChatRole.User, "Hello")]); + + // Assert + Assert.Equal(5, groups.Groups[0].ByteCount); + } + + [Fact] + public void Create_ComputesByteCount_MultiByteChars() + { + // Arrange — "café" has a multi-byte 'é' (2 bytes in UTF-8) → 5 bytes total + MessageGroups groups = MessageGroups.Create([new ChatMessage(ChatRole.User, "café")]); + + // Assert + Assert.Equal(5, groups.Groups[0].ByteCount); + } + + [Fact] + public void Create_ComputesByteCount_MultipleMessagesInGroup() + { + // Arrange — ToolCall group: assistant (tool call, null text) + tool result "OK" (2 bytes) + ChatMessage assistantMsg = new(ChatRole.Assistant, [new FunctionCallContent("call1", "fn")]); + ChatMessage toolResult = new(ChatRole.Tool, "OK"); + MessageGroups groups = MessageGroups.Create([assistantMsg, toolResult]); + + // Assert — single ToolCall group with 2 messages + Assert.Single(groups.Groups); + Assert.Equal(2, groups.Groups[0].MessageCount); + Assert.Equal(2, groups.Groups[0].ByteCount); // "OK" = 2 bytes, assistant text is null + } + + [Fact] + public void Create_DefaultTokenCount_IsHeuristic() + { + // Arrange — "Hello world test data!" = 22 UTF-8 bytes → 22 / 4 = 5 estimated tokens + MessageGroups groups = MessageGroups.Create([new ChatMessage(ChatRole.User, "Hello world test data!")]); + + // Assert + Assert.Equal(22, groups.Groups[0].ByteCount); + Assert.Equal(22 / 4, groups.Groups[0].TokenCount); + } + + [Fact] + public void Create_NullText_HasZeroCounts() + { + // Arrange — message with no text (e.g., pure function call) + ChatMessage msg = new(ChatRole.Assistant, [new FunctionCallContent("call1", "get_weather")]); + ChatMessage tool = new(ChatRole.Tool, string.Empty); + MessageGroups groups = MessageGroups.Create([msg, tool]); + + // Assert + Assert.Equal(2, groups.Groups[0].MessageCount); + Assert.Equal(0, groups.Groups[0].ByteCount); + Assert.Equal(0, groups.Groups[0].TokenCount); + } + + [Fact] + public void TotalAggregates_SumAllGroups() + { + // Arrange + MessageGroups groups = MessageGroups.Create( + [ + new ChatMessage(ChatRole.User, "AAAA"), // 4 bytes + new ChatMessage(ChatRole.Assistant, "BBBB"), // 4 bytes + ]); + + groups.Groups[0].IsExcluded = true; + + // Act & Assert — totals include excluded groups + Assert.Equal(2, groups.TotalGroupCount); + Assert.Equal(2, groups.TotalMessageCount); + Assert.Equal(8, groups.TotalByteCount); + Assert.Equal(2, groups.TotalTokenCount); // Each group: 4 bytes / 4 = 1 token, 2 groups = 2 + } + + [Fact] + public void IncludedAggregates_ExcludeMarkedGroups() + { + // Arrange + MessageGroups groups = MessageGroups.Create( + [ + new ChatMessage(ChatRole.User, "AAAA"), // 4 bytes + new ChatMessage(ChatRole.Assistant, "BBBB"), // 4 bytes + new ChatMessage(ChatRole.User, "CCCC"), // 4 bytes + ]); + + groups.Groups[0].IsExcluded = true; + + // Act & Assert + Assert.Equal(3, groups.TotalGroupCount); + Assert.Equal(2, groups.IncludedGroupCount); + Assert.Equal(3, groups.TotalMessageCount); + Assert.Equal(2, groups.IncludedMessageCount); + Assert.Equal(12, groups.TotalByteCount); + Assert.Equal(8, groups.IncludedByteCount); + Assert.Equal(3, groups.TotalTokenCount); // 12 / 4 = 3 (across 3 groups of 4 bytes each = 1+1+1) + Assert.Equal(2, groups.IncludedTokenCount); // 8 / 4 = 2 (2 included groups of 4 bytes = 1+1) + } + + [Fact] + public void ToolCallGroup_AggregatesAcrossMessages() + { + // Arrange — tool call group with assistant "Ask" (3 bytes) + tool result "OK" (2 bytes) + ChatMessage assistantMsg = new(ChatRole.Assistant, [new FunctionCallContent("call1", "fn")]); + ChatMessage toolResult = new(ChatRole.Tool, "OK"); + + MessageGroups groups = MessageGroups.Create([assistantMsg, toolResult]); + + // Assert — single group with 2 messages + Assert.Single(groups.Groups); + Assert.Equal(2, groups.Groups[0].MessageCount); + Assert.Equal(2, groups.Groups[0].ByteCount); // assistant text is null (function call), tool result is "OK" = 2 bytes + Assert.Equal(1, groups.TotalGroupCount); + Assert.Equal(2, groups.TotalMessageCount); + } + + [Fact] + public void Create_AssignsTurnIndices_SingleTurn() + { + // Arrange — System (no turn), User + Assistant = turn 1 + MessageGroups groups = MessageGroups.Create( + [ + new ChatMessage(ChatRole.System, "You are helpful."), + new ChatMessage(ChatRole.User, "Hello"), + new ChatMessage(ChatRole.Assistant, "Hi!"), + ]); + + // Assert + Assert.Null(groups.Groups[0].TurnIndex); // System + Assert.Equal(1, groups.Groups[1].TurnIndex); // User + Assert.Equal(1, groups.Groups[2].TurnIndex); // Assistant + Assert.Equal(1, groups.TotalTurnCount); + Assert.Equal(1, groups.IncludedTurnCount); + } + + [Fact] + public void Create_AssignsTurnIndices_MultiTurn() + { + // Arrange — 3 user turns + MessageGroups groups = MessageGroups.Create( + [ + new ChatMessage(ChatRole.System, "System prompt."), + new ChatMessage(ChatRole.User, "Q1"), + new ChatMessage(ChatRole.Assistant, "A1"), + new ChatMessage(ChatRole.User, "Q2"), + new ChatMessage(ChatRole.Assistant, "A2"), + new ChatMessage(ChatRole.User, "Q3"), + ]); + + // Assert — 6 groups: System(null), User(1), Assistant(1), User(2), Assistant(2), User(3) + Assert.Null(groups.Groups[0].TurnIndex); + Assert.Equal(1, groups.Groups[1].TurnIndex); + Assert.Equal(1, groups.Groups[2].TurnIndex); + Assert.Equal(2, groups.Groups[3].TurnIndex); + Assert.Equal(2, groups.Groups[4].TurnIndex); + Assert.Equal(3, groups.Groups[5].TurnIndex); + Assert.Equal(3, groups.TotalTurnCount); + } + + [Fact] + public void Create_TurnSpansToolCallGroups() + { + // Arrange — turn 1 includes User, ToolCall, AssistantText + ChatMessage assistantToolCall = new(ChatRole.Assistant, [new FunctionCallContent("call1", "get_weather")]); + ChatMessage toolResult = new(ChatRole.Tool, "Sunny"); + + MessageGroups groups = MessageGroups.Create( + [ + new ChatMessage(ChatRole.User, "What's the weather?"), + assistantToolCall, + toolResult, + new ChatMessage(ChatRole.Assistant, "The weather is sunny!"), + ]); + + // Assert — all 3 groups belong to turn 1 + Assert.Equal(3, groups.Groups.Count); + Assert.Equal(1, groups.Groups[0].TurnIndex); // User + Assert.Equal(1, groups.Groups[1].TurnIndex); // ToolCall + Assert.Equal(1, groups.Groups[2].TurnIndex); // AssistantText + Assert.Equal(1, groups.TotalTurnCount); + } + + [Fact] + public void GetTurnGroups_ReturnsGroupsForSpecificTurn() + { + // Arrange + MessageGroups groups = MessageGroups.Create( + [ + new ChatMessage(ChatRole.System, "System."), + new ChatMessage(ChatRole.User, "Q1"), + new ChatMessage(ChatRole.Assistant, "A1"), + new ChatMessage(ChatRole.User, "Q2"), + new ChatMessage(ChatRole.Assistant, "A2"), + ]); + + // Act + List turn1 = [.. groups.GetTurnGroups(1)]; + List turn2 = [.. groups.GetTurnGroups(2)]; + + // Assert + Assert.Equal(2, turn1.Count); + Assert.Equal(MessageGroupKind.User, turn1[0].Kind); + Assert.Equal(MessageGroupKind.AssistantText, turn1[1].Kind); + Assert.Equal(2, turn2.Count); + Assert.Equal(MessageGroupKind.User, turn2[0].Kind); + Assert.Equal(MessageGroupKind.AssistantText, turn2[1].Kind); + } + + [Fact] + public void IncludedTurnCount_ReflectsExclusions() + { + // Arrange — 2 turns, exclude all groups in turn 1 + MessageGroups groups = MessageGroups.Create( + [ + new ChatMessage(ChatRole.User, "Q1"), + new ChatMessage(ChatRole.Assistant, "A1"), + new ChatMessage(ChatRole.User, "Q2"), + new ChatMessage(ChatRole.Assistant, "A2"), + ]); + + groups.Groups[0].IsExcluded = true; // User Q1 (turn 1) + groups.Groups[1].IsExcluded = true; // Assistant A1 (turn 1) + + // Assert + Assert.Equal(2, groups.TotalTurnCount); + Assert.Equal(1, groups.IncludedTurnCount); // Only turn 2 has included groups + } + + [Fact] + public void TotalTurnCount_ZeroWhenNoUserMessages() + { + // Arrange — only system messages + MessageGroups groups = MessageGroups.Create( + [ + new ChatMessage(ChatRole.System, "System."), + ]); + + // Assert + Assert.Equal(0, groups.TotalTurnCount); + Assert.Equal(0, groups.IncludedTurnCount); + } + + [Fact] + public void IncludedTurnCount_PartialExclusion_StillCountsTurn() + { + // Arrange — turn 1 has 2 groups, only one excluded + MessageGroups groups = MessageGroups.Create( + [ + new ChatMessage(ChatRole.User, "Q1"), + new ChatMessage(ChatRole.Assistant, "A1"), + ]); + + groups.Groups[1].IsExcluded = true; // Exclude assistant but user is still included + + // Assert — turn 1 still has one included group + Assert.Equal(1, groups.TotalTurnCount); + Assert.Equal(1, groups.IncludedTurnCount); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/PipelineCompactionStrategyTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/PipelineCompactionStrategyTests.cs new file mode 100644 index 0000000000..eb5cda0997 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/PipelineCompactionStrategyTests.cs @@ -0,0 +1,282 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Compaction; +using Microsoft.Extensions.AI; +using Moq; + +namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction; + +/// +/// Contains tests for the class. +/// +public class PipelineCompactionStrategyTests +{ + [Fact] + public async Task CompactAsync_ExecutesAllStrategiesInOrder() + { + // Arrange + List executionOrder = []; + Mock strategy1 = new(); + strategy1.Setup(s => s.CompactAsync(It.IsAny(), It.IsAny())) + .Callback(() => executionOrder.Add("first")) + .ReturnsAsync(false); + + Mock strategy2 = new(); + strategy2.Setup(s => s.CompactAsync(It.IsAny(), It.IsAny())) + .Callback(() => executionOrder.Add("second")) + .ReturnsAsync(false); + + PipelineCompactionStrategy pipeline = new(strategy1.Object, strategy2.Object); + MessageGroups groups = MessageGroups.Create([new ChatMessage(ChatRole.User, "Hello")]); + + // Act + await pipeline.CompactAsync(groups); + + // Assert + Assert.Equal(["first", "second"], executionOrder); + } + + [Fact] + public async Task CompactAsync_ReturnsFalse_WhenNoStrategyCompacts() + { + // Arrange + Mock strategy1 = new(); + strategy1.Setup(s => s.CompactAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(false); + + PipelineCompactionStrategy pipeline = new(strategy1.Object); + MessageGroups groups = MessageGroups.Create([new ChatMessage(ChatRole.User, "Hello")]); + + // Act + bool result = await pipeline.CompactAsync(groups); + + // Assert + Assert.False(result); + } + + [Fact] + public async Task CompactAsync_ReturnsTrue_WhenAnyStrategyCompacts() + { + // Arrange + Mock strategy1 = new(); + strategy1.Setup(s => s.CompactAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(false); + + Mock strategy2 = new(); + strategy2.Setup(s => s.CompactAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(true); + + PipelineCompactionStrategy pipeline = new(strategy1.Object, strategy2.Object); + MessageGroups groups = MessageGroups.Create([new ChatMessage(ChatRole.User, "Hello")]); + + // Act + bool result = await pipeline.CompactAsync(groups); + + // Assert + Assert.True(result); + } + + [Fact] + public async Task CompactAsync_ContinuesAfterFirstCompaction_WhenEarlyStopDisabled() + { + // Arrange + Mock strategy1 = new(); + strategy1.Setup(s => s.CompactAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(true); + + Mock strategy2 = new(); + strategy2.Setup(s => s.CompactAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(false); + + PipelineCompactionStrategy pipeline = new(strategy1.Object, strategy2.Object); + MessageGroups groups = MessageGroups.Create([new ChatMessage(ChatRole.User, "Hello")]); + + // Act + await pipeline.CompactAsync(groups); + + // Assert — both strategies were called + strategy1.Verify(s => s.CompactAsync(It.IsAny(), It.IsAny()), Times.Once); + strategy2.Verify(s => s.CompactAsync(It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task CompactAsync_StopsEarly_WhenTargetReached() + { + // Arrange — first strategy reduces to target + Mock strategy1 = new(); + strategy1.Setup(s => s.CompactAsync(It.IsAny(), It.IsAny())) + .Callback((groups, _) => + { + // Exclude the first group to bring count down + groups.Groups[0].IsExcluded = true; + }) + .ReturnsAsync(true); + + Mock strategy2 = new(); + strategy2.Setup(s => s.CompactAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(false); + + PipelineCompactionStrategy pipeline = new(strategy1.Object, strategy2.Object) + { + EarlyStop = true, + TargetIncludedGroupCount = 2, + }; + + MessageGroups groups = MessageGroups.Create( + [ + new ChatMessage(ChatRole.User, "First"), + new ChatMessage(ChatRole.Assistant, "Response"), + new ChatMessage(ChatRole.User, "Second"), + ]); + + // Act + bool result = await pipeline.CompactAsync(groups); + + // Assert — strategy2 should not have been called + Assert.True(result); + strategy1.Verify(s => s.CompactAsync(It.IsAny(), It.IsAny()), Times.Once); + strategy2.Verify(s => s.CompactAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task CompactAsync_DoesNotStopEarly_WhenTargetNotReached() + { + // Arrange — first strategy does NOT bring count to target + Mock strategy1 = new(); + strategy1.Setup(s => s.CompactAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(false); + + Mock strategy2 = new(); + strategy2.Setup(s => s.CompactAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(false); + + PipelineCompactionStrategy pipeline = new(strategy1.Object, strategy2.Object) + { + EarlyStop = true, + TargetIncludedGroupCount = 1, + }; + + MessageGroups groups = MessageGroups.Create( + [ + new ChatMessage(ChatRole.User, "First"), + new ChatMessage(ChatRole.User, "Second"), + new ChatMessage(ChatRole.User, "Third"), + ]); + + // Act + await pipeline.CompactAsync(groups); + + // Assert — both strategies were called since target was never reached + strategy1.Verify(s => s.CompactAsync(It.IsAny(), It.IsAny()), Times.Once); + strategy2.Verify(s => s.CompactAsync(It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task CompactAsync_EarlyStopIgnored_WhenNoTargetSet() + { + // Arrange + Mock strategy1 = new(); + strategy1.Setup(s => s.CompactAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(true); + + Mock strategy2 = new(); + strategy2.Setup(s => s.CompactAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(false); + + PipelineCompactionStrategy pipeline = new(strategy1.Object, strategy2.Object) + { + EarlyStop = true, + // TargetIncludedGroupCount is null + }; + + MessageGroups groups = MessageGroups.Create([new ChatMessage(ChatRole.User, "Hello")]); + + // Act + await pipeline.CompactAsync(groups); + + // Assert — both strategies called because no target to check against + strategy1.Verify(s => s.CompactAsync(It.IsAny(), It.IsAny()), Times.Once); + strategy2.Verify(s => s.CompactAsync(It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task CompactAsync_ComposesStrategies_EndToEnd() + { + // Arrange — pipeline: first exclude oldest 2 non-system groups, then exclude 2 more + Mock phase1 = new(); + phase1.Setup(s => s.CompactAsync(It.IsAny(), It.IsAny())) + .Callback((groups, _) => + { + int excluded = 0; + foreach (MessageGroup group in groups.Groups) + { + if (!group.IsExcluded && group.Kind != MessageGroupKind.System && excluded < 2) + { + group.IsExcluded = true; + excluded++; + } + } + }) + .ReturnsAsync(true); + + Mock phase2 = new(); + phase2.Setup(s => s.CompactAsync(It.IsAny(), It.IsAny())) + .Callback((groups, _) => + { + int excluded = 0; + foreach (MessageGroup group in groups.Groups) + { + if (!group.IsExcluded && group.Kind != MessageGroupKind.System && excluded < 2) + { + group.IsExcluded = true; + excluded++; + } + } + }) + .ReturnsAsync(true); + + PipelineCompactionStrategy pipeline = new(phase1.Object, phase2.Object); + + MessageGroups groups = MessageGroups.Create( + [ + new ChatMessage(ChatRole.System, "You are helpful."), + new ChatMessage(ChatRole.User, "Q1"), + new ChatMessage(ChatRole.Assistant, "A1"), + new ChatMessage(ChatRole.User, "Q2"), + new ChatMessage(ChatRole.Assistant, "A2"), + new ChatMessage(ChatRole.User, "Q3"), + ]); + + // Act + bool result = await pipeline.CompactAsync(groups); + + // Assert — system is preserved, phase1 excluded Q1+A1, phase2 excluded Q2+A2 → System + Q3 + Assert.True(result); + Assert.Equal(2, groups.IncludedGroupCount); + + List included = [.. groups.GetIncludedMessages()]; + Assert.Equal(2, included.Count); + Assert.Equal("You are helpful.", included[0].Text); + Assert.Equal("Q3", included[1].Text); + + phase1.Verify(s => s.CompactAsync(It.IsAny(), It.IsAny()), Times.Once); + phase2.Verify(s => s.CompactAsync(It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task CompactAsync_EmptyPipeline_ReturnsFalseAsync() + { + // Arrange + PipelineCompactionStrategy pipeline = new(new List()); + MessageGroups groups = MessageGroups.Create([new ChatMessage(ChatRole.User, "Hello")]); + + // Act + bool result = await pipeline.CompactAsync(groups); + + // Assert + Assert.False(result); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/TruncationCompactionStrategyTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/TruncationCompactionStrategyTests.cs new file mode 100644 index 0000000000..41855884a9 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/TruncationCompactionStrategyTests.cs @@ -0,0 +1,191 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading.Tasks; +using Microsoft.Agents.AI.Compaction; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.UnitTests.Compaction; + +/// +/// Contains tests for the class. +/// +public class TruncationCompactionStrategyTests +{ + [Fact] + public async Task CompactAsync_BelowLimit_ReturnsFalseAsync() + { + // Arrange + TruncationCompactionStrategy strategy = new(maxGroups: 5); + MessageGroups groups = MessageGroups.Create( + [ + new ChatMessage(ChatRole.User, "Hello"), + new ChatMessage(ChatRole.Assistant, "Hi!"), + ]); + + // Act + bool result = await strategy.CompactAsync(groups); + + // Assert + Assert.False(result); + Assert.Equal(2, groups.IncludedGroupCount); + } + + [Fact] + public async Task CompactAsync_AtLimit_ReturnsFalseAsync() + { + // Arrange + TruncationCompactionStrategy strategy = new(maxGroups: 2); + MessageGroups groups = MessageGroups.Create( + [ + new ChatMessage(ChatRole.User, "Hello"), + new ChatMessage(ChatRole.Assistant, "Hi!"), + ]); + + // Act + bool result = await strategy.CompactAsync(groups); + + // Assert + Assert.False(result); + } + + [Fact] + public async Task CompactAsync_ExceedsLimit_ExcludesOldestGroupsAsync() + { + // Arrange + TruncationCompactionStrategy strategy = new(maxGroups: 2); + ChatMessage msg1 = new(ChatRole.User, "First"); + ChatMessage msg2 = new(ChatRole.Assistant, "Response 1"); + ChatMessage msg3 = new(ChatRole.User, "Second"); + ChatMessage msg4 = new(ChatRole.Assistant, "Response 2"); + + MessageGroups groups = MessageGroups.Create([msg1, msg2, msg3, msg4]); + + // Act + bool result = await strategy.CompactAsync(groups); + + // Assert + Assert.True(result); + Assert.Equal(2, groups.IncludedGroupCount); + Assert.True(groups.Groups[0].IsExcluded); + Assert.True(groups.Groups[1].IsExcluded); + Assert.False(groups.Groups[2].IsExcluded); + Assert.False(groups.Groups[3].IsExcluded); + } + + [Fact] + public async Task CompactAsync_PreservesSystemMessages_WhenEnabledAsync() + { + // Arrange + TruncationCompactionStrategy strategy = new(maxGroups: 2, preserveSystemMessages: true); + ChatMessage systemMsg = new(ChatRole.System, "You are helpful."); + ChatMessage msg1 = new(ChatRole.User, "First"); + ChatMessage msg2 = new(ChatRole.Assistant, "Response 1"); + ChatMessage msg3 = new(ChatRole.User, "Second"); + + MessageGroups groups = MessageGroups.Create([systemMsg, msg1, msg2, msg3]); + + // Act + bool result = await strategy.CompactAsync(groups); + + // Assert + Assert.True(result); + // System message should be preserved + Assert.False(groups.Groups[0].IsExcluded); + Assert.Equal(MessageGroupKind.System, groups.Groups[0].Kind); + // Oldest non-system groups should be excluded + Assert.True(groups.Groups[1].IsExcluded); + Assert.True(groups.Groups[2].IsExcluded); + // Most recent should remain + Assert.False(groups.Groups[3].IsExcluded); + } + + [Fact] + public async Task CompactAsync_DoesNotPreserveSystemMessages_WhenDisabledAsync() + { + // Arrange + TruncationCompactionStrategy strategy = new(maxGroups: 2, preserveSystemMessages: false); + ChatMessage systemMsg = new(ChatRole.System, "You are helpful."); + ChatMessage msg1 = new(ChatRole.User, "First"); + ChatMessage msg2 = new(ChatRole.Assistant, "Response"); + ChatMessage msg3 = new(ChatRole.User, "Second"); + + MessageGroups groups = MessageGroups.Create([systemMsg, msg1, msg2, msg3]); + + // Act + bool result = await strategy.CompactAsync(groups); + + // Assert + Assert.True(result); + // System message should be excluded (oldest) + Assert.True(groups.Groups[0].IsExcluded); + Assert.True(groups.Groups[1].IsExcluded); + Assert.False(groups.Groups[2].IsExcluded); + Assert.False(groups.Groups[3].IsExcluded); + } + + [Fact] + public async Task CompactAsync_PreservesToolCallGroupAtomicityAsync() + { + // Arrange + TruncationCompactionStrategy strategy = new(maxGroups: 1); + + ChatMessage assistantToolCall = new(ChatRole.Assistant, [new FunctionCallContent("call1", "get_weather")]); + ChatMessage toolResult = new(ChatRole.Tool, "Sunny"); + ChatMessage finalResponse = new(ChatRole.User, "Thanks!"); + + MessageGroups groups = MessageGroups.Create([assistantToolCall, toolResult, finalResponse]); + + // Act + bool result = await strategy.CompactAsync(groups); + + // Assert + Assert.True(result); + // Tool call group should be excluded as one atomic unit + Assert.True(groups.Groups[0].IsExcluded); + Assert.Equal(MessageGroupKind.ToolCall, groups.Groups[0].Kind); + Assert.Equal(2, groups.Groups[0].Messages.Count); + Assert.False(groups.Groups[1].IsExcluded); + } + + [Fact] + public async Task CompactAsync_SetsExcludeReasonAsync() + { + // Arrange + TruncationCompactionStrategy strategy = new(maxGroups: 1); + MessageGroups groups = MessageGroups.Create( + [ + new ChatMessage(ChatRole.User, "Old"), + new ChatMessage(ChatRole.User, "New"), + ]); + + // Act + await strategy.CompactAsync(groups); + + // Assert + Assert.NotNull(groups.Groups[0].ExcludeReason); + Assert.Contains("TruncationCompactionStrategy", groups.Groups[0].ExcludeReason); + } + + [Fact] + public async Task CompactAsync_SkipsAlreadyExcludedGroupsAsync() + { + // Arrange + TruncationCompactionStrategy strategy = new(maxGroups: 1); + MessageGroups groups = MessageGroups.Create( + [ + new ChatMessage(ChatRole.User, "Already excluded"), + new ChatMessage(ChatRole.User, "Included 1"), + new ChatMessage(ChatRole.User, "Included 2"), + ]); + groups.Groups[0].IsExcluded = true; + + // Act + bool result = await strategy.CompactAsync(groups); + + // Assert + Assert.True(result); + Assert.True(groups.Groups[0].IsExcluded); // was already excluded + Assert.True(groups.Groups[1].IsExcluded); // newly excluded + Assert.False(groups.Groups[2].IsExcluded); // kept + } +}