From 23cf75be3cb863a295636e82de2323fa96931e50 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Wed, 4 Mar 2026 18:12:23 -0800 Subject: [PATCH 01/22] Checkpoint --- dotnet/Directory.Packages.props | 1 + dotnet/agent-working-dotnet.slnx | 447 +++++++++++++++ .../ChatHistoryProvider.cs | 30 + .../Compaction/ICompactionStrategy.cs | 38 ++ .../Compaction/MessageGroup.cs | 108 ++++ .../Compaction/MessageGroupKind.cs | 49 ++ .../Compaction/MessageGroups.cs | 312 +++++++++++ .../Compaction/PipelineCompactionStrategy.cs | 82 +++ .../InMemoryChatHistoryProvider.cs | 37 ++ .../InMemoryChatHistoryProviderOptions.cs | 16 + .../Microsoft.Agents.AI.Abstractions.csproj | 1 + .../ChatClient/ChatClientAgentOptions.cs | 22 + .../ChatClient/ChatClientExtensions.cs | 9 +- .../ChatClient/CompactingChatClient.cs | 75 +++ .../SummarizationCompactionStrategy.cs | 135 +++++ .../TruncationCompactionStrategy.cs | 80 +++ ...emoryChatHistoryProviderCompactionTests.cs | 268 +++++++++ .../Compaction/MessageGroupsTests.cs | 524 ++++++++++++++++++ .../PipelineCompactionStrategyTests.cs | 282 ++++++++++ .../TruncationCompactionStrategyTests.cs | 191 +++++++ 20 files changed, 2706 insertions(+), 1 deletion(-) create mode 100644 dotnet/agent-working-dotnet.slnx create mode 100644 dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ICompactionStrategy.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/MessageGroup.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/MessageGroupKind.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/MessageGroups.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/PipelineCompactionStrategy.cs create mode 100644 dotnet/src/Microsoft.Agents.AI/ChatClient/CompactingChatClient.cs create mode 100644 dotnet/src/Microsoft.Agents.AI/Compaction/SummarizationCompactionStrategy.cs create mode 100644 dotnet/src/Microsoft.Agents.AI/Compaction/TruncationCompactionStrategy.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/InMemoryChatHistoryProviderCompactionTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/MessageGroupsTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/PipelineCompactionStrategyTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/TruncationCompactionStrategyTests.cs 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.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 @@ protected virtual ValueTask InvokedCoreAsync(InvokedContext context, Cancellatio 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.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 InMemoryChatHistoryProvider(InMemoryChatHistoryProviderOptions? options = options?.JsonSerializerOptions); this.ChatReducer = options?.ChatReducer; this.ReducerTriggerEvent = options?.ReducerTriggerEvent ?? InMemoryChatHistoryProviderOptions.ChatReducerTriggerEvent.BeforeMessagesRetrieval; + this.CompactionStrategy = options?.CompactionStrategy; } /// @@ -61,6 +63,11 @@ public InMemoryChatHistoryProvider(InMemoryChatHistoryProviderOptions? options = /// 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 @@ protected override async ValueTask StoreChatHistoryAsync(InvokedContext context, { 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 ChatClientAgentOptions Clone() 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 @@ internal static IChatClient WithDefaultAgentMiddleware(this IChatClient chatClie { 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 + } +} From fcd60daed51dd8f56a9755294d72ff6e5cd673cf Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Wed, 4 Mar 2026 23:31:18 -0800 Subject: [PATCH 02/22] Checkpoint --- dotnet/agent-working-dotnet.slnx | 1 + .../Agent_Step18_CompactionPipeline.csproj | 21 + .../Program.cs | 109 ++++ .../ChatHistoryProvider.cs | 30 - .../Compaction/ICompactionStrategy.cs | 8 +- .../Compaction/MessageGroup.cs | 10 +- .../{MessageGroups.cs => MessageIndex.cs} | 109 +++- .../Compaction/PipelineCompactionStrategy.cs | 7 +- .../InMemoryChatHistoryProvider.cs | 57 +- .../InMemoryChatHistoryProviderOptions.cs | 15 - .../ChatClient/ChatClientAgentOptions.cs | 4 - .../ChatClient/ChatClientExtensions.cs | 1 + .../ChatClient/CompactingChatClient.cs | 75 --- .../Compaction/CompactingChatClient.cs | 133 +++++ .../SummarizationCompactionStrategy.cs | 2 +- .../TruncationCompactionStrategy.cs | 2 +- ...emoryChatHistoryProviderCompactionTests.cs | 533 +++++++++--------- ...ageGroupsTests.cs => MessageIndexTests.cs} | 60 +- .../PipelineCompactionStrategyTests.cs | 92 +-- .../TruncationCompactionStrategyTests.cs | 18 +- 20 files changed, 742 insertions(+), 545 deletions(-) create mode 100644 dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/Agent_Step18_CompactionPipeline.csproj create mode 100644 dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/Program.cs rename dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/{MessageGroups.cs => MessageIndex.cs} (72%) delete mode 100644 dotnet/src/Microsoft.Agents.AI/ChatClient/CompactingChatClient.cs create mode 100644 dotnet/src/Microsoft.Agents.AI/Compaction/CompactingChatClient.cs rename dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/{MessageGroupsTests.cs => MessageIndexTests.cs} (89%) diff --git a/dotnet/agent-working-dotnet.slnx b/dotnet/agent-working-dotnet.slnx index 651b185a23..60542e7969 100644 --- a/dotnet/agent-working-dotnet.slnx +++ b/dotnet/agent-working-dotnet.slnx @@ -56,6 +56,7 @@ + diff --git a/dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/Agent_Step18_CompactionPipeline.csproj b/dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/Agent_Step18_CompactionPipeline.csproj new file mode 100644 index 0000000000..0f9de7c359 --- /dev/null +++ b/dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/Agent_Step18_CompactionPipeline.csproj @@ -0,0 +1,21 @@ + + + + Exe + net10.0 + + enable + enable + + + + + + + + + + + + + diff --git a/dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/Program.cs b/dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/Program.cs new file mode 100644 index 0000000000..82a38c538c --- /dev/null +++ b/dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/Program.cs @@ -0,0 +1,109 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample demonstrates how to use a ChatHistoryCompactionPipeline as the ChatReducer for an agent's +// in-memory chat history. The pipeline chains multiple compaction strategies from gentle to aggressive: +// 1. ToolResultCompactionStrategy - Collapses old tool-call groups into concise summaries +// 2. SummarizationCompactionStrategy - LLM-compresses older conversation spans +// 3. SlidingWindowCompactionStrategy - Keeps only the most recent N user turns +// 4. TruncationCompactionStrategy - Emergency token-budget backstop + +using System.ComponentModel; +using Azure.AI.OpenAI; +using Azure.Identity; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Compaction; +using Microsoft.Extensions.AI; + +var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); +var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; + +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. +AzureOpenAIClient openAIClient = new(new Uri(endpoint), new DefaultAzureCredential()); + +// Create a chat client for the agent and a separate one for the summarization strategy. +// Using the same model for simplicity; in production, use a smaller/cheaper model for summarization. +IChatClient agentChatClient = openAIClient.GetChatClient(deploymentName).AsIChatClient(); +IChatClient summarizerChatClient = openAIClient.GetChatClient(deploymentName).AsIChatClient(); + +// Define a tool the agent can use, so we can see tool-result compaction in action. +[Description("Look up the current price of a product by name.")] +static string LookupPrice([Description("The product name to look up.")] string productName) => + productName.ToUpperInvariant() switch + { + "LAPTOP" => "The laptop costs $999.99.", + "KEYBOARD" => "The keyboard costs $79.99.", + "MOUSE" => "The mouse costs $29.99.", + _ => $"Sorry, I don't have pricing for '{productName}'." + }; + +// Configure the compaction pipeline with one of each strategy, ordered least to most aggressive. +//const int MaxTokens = 512; +//const int MaxTurns = 4; +const int MaxGroups = 2; + +PipelineCompactionStrategy compactionPipeline = + new(// 1. Gentle: collapse old tool-call groups into short summaries like "[Tool calls: LookupPrice]" + //new ToolResultCompactionStrategy(MaxTokens, preserveRecentGroups: 2), + + // 2. Moderate: use an LLM to summarize older conversation spans into a concise message + new SummarizationCompactionStrategy(summarizerChatClient, MaxGroups) + + // 3. Aggressive: keep only the last N user turns and their responses + //new SlidingWindowCompactionStrategy(MaxTurns), + + // 4. Emergency: drop oldest groups until under the token budget + //new TruncationCompactionStrategy(MaxGroups) + ); + +// Create the agent with an in-memory chat history provider whose reducer is the compaction pipeline. +AIAgent agent = + agentChatClient.AsAIAgent( + new ChatClientAgentOptions + { + Name = "ShoppingAssistant", + ChatOptions = new() + { + Instructions = + """ + You are a helpful, but long winded, shopping assistant. + Help the user look up prices and compare products. + When responding, Be sure to be extra descriptive and use as + many words as possible without sounding ridiculous. + """, + Tools = [AIFunctionFactory.Create(LookupPrice)], + }, + CompactionStrategy = compactionPipeline, + }); + +AgentSession session = await agent.CreateSessionAsync(); + +// Helper to print chat history size +void PrintChatHistory() +{ + if (session.TryGetInMemoryChatHistory(out var history)) + { + Console.WriteLine($" [Chat history: {history.Count} messages]\n"); + } +} + +// Run a multi-turn conversation with tool calls to exercise the pipeline. +string[] prompts = +[ + "What's the price of a laptop?", + "How about a keyboard?", + "And a mouse?", + "Which product is the cheapest?", + "Can you compare the laptop and the keyboard for me?", + "What was the first product I asked about?", + "Thank you!", +]; + +foreach (string prompt in prompts) +{ + Console.WriteLine($"User: {prompt}"); + Console.WriteLine($"Agent: {await agent.RunAsync(prompt, session)}"); + + PrintChatHistory(); +} diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/ChatHistoryProvider.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/ChatHistoryProvider.cs index cdd21e9e1c..ad3f3aacfb 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/ChatHistoryProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/ChatHistoryProvider.cs @@ -5,7 +5,6 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; -using Microsoft.Agents.AI.Compaction; using Microsoft.Extensions.AI; using Microsoft.Shared.Diagnostics; @@ -270,35 +269,6 @@ protected virtual ValueTask InvokedCoreAsync(InvokedContext context, Cancellatio 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 index 615317062d..c894dd0d19 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ICompactionStrategy.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ICompactionStrategy.cs @@ -6,11 +6,11 @@ namespace Microsoft.Agents.AI.Compaction; /// -/// Defines a strategy for compacting a to reduce context size. +/// Defines a strategy for compacting a to reduce context size. /// /// /// -/// Compaction strategies operate on instances, which organize messages +/// 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). /// @@ -23,7 +23,7 @@ namespace Microsoft.Agents.AI.Compaction; /// /// /// -/// Multiple strategies can be composed by applying them sequentially to the same . +/// Multiple strategies can be composed by applying them sequentially to the same . /// /// public interface ICompactionStrategy @@ -34,5 +34,5 @@ public interface ICompactionStrategy /// 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); + Task CompactAsync(MessageIndex 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 index d443b15d87..f8d41a41fb 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/MessageGroup.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/MessageGroup.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System.Collections.Generic; +using System.Text.Json.Serialization; using Microsoft.Extensions.AI; namespace Microsoft.Agents.AI.Compaction; @@ -21,8 +22,8 @@ namespace Microsoft.Agents.AI.Compaction; /// /// /// 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. +/// 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 @@ -32,7 +33,7 @@ public sealed class MessageGroup /// /// /// When this key is present with a value of , the message is classified as - /// by . + /// by . /// public static readonly string SummaryPropertyKey = "_is_summary"; @@ -47,6 +48,7 @@ public sealed class MessageGroup /// The zero-based user turn this group belongs to, or for groups that precede /// the first user message (e.g., system messages). /// + [JsonConstructor] public MessageGroup(MessageGroupKind kind, IReadOnlyList messages, int byteCount, int tokenCount, int? turnIndex = null) { this.Kind = kind; @@ -97,7 +99,7 @@ public MessageGroup(MessageGroupKind kind, IReadOnlyList messages, /// /// /// Excluded groups are preserved in the collection for diagnostics or storage purposes - /// but are not included when calling . + /// but are not included when calling . /// public bool IsExcluded { get; set; } diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/MessageGroups.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/MessageIndex.cs similarity index 72% rename from dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/MessageGroups.cs rename to dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/MessageIndex.cs index 5661c1516a..c018774d91 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/MessageGroups.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/MessageIndex.cs @@ -13,7 +13,7 @@ namespace Microsoft.Agents.AI.Compaction; /// /// /// -/// provides structural grouping of messages into logical units that +/// 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. /// @@ -27,9 +27,16 @@ namespace Microsoft.Agents.AI.Compaction; /// and . The collection provides aggregate properties for both /// the total (all groups) and included (non-excluded groups only) counts. /// +/// +/// Instances created via track internal state that enables efficient incremental +/// updates via . This allows caching a instance and +/// appending only new messages without reprocessing the entire history. +/// /// -public sealed class MessageGroups +public sealed class MessageIndex { + private int _currentTurn; + /// /// Gets the list of message groups in this collection. /// @@ -41,25 +48,43 @@ public sealed class MessageGroups public Tokenizer? Tokenizer { get; } /// - /// Initializes a new instance of the class with the specified groups. + /// Gets the number of raw messages that have been processed into groups. + /// + /// + /// This value is set by and updated by . + /// It is used by to determine which messages are new and need processing. + /// + public int ProcessedMessageCount { get; private set; } + + /// + /// 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) + public MessageIndex(IList groups, Tokenizer? tokenizer = null) { this.Groups = groups; this.Tokenizer = tokenizer; + + for (int index = groups.Count - 1; index >= 0; --index) + { + if (this.Groups[0].TurnIndex.HasValue) + { + this._currentTurn = this.Groups[0].TurnIndex!.Value; + break; + } + } } /// - /// Creates a from a flat list of instances. + /// 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. + /// A new with messages organized into logical groups. /// /// The grouping algorithm: /// @@ -70,11 +95,61 @@ public MessageGroups(IList groups, Tokenizer? tokenizer = null) /// Assistant messages without tool calls become groups. /// /// - public static MessageGroups Create(IList messages, Tokenizer? tokenizer = null) + public static MessageIndex Create(IList messages, Tokenizer? tokenizer = null) + { + MessageIndex instance = new(new List(), tokenizer); + instance.AppendFromMessages(messages, 0); + return instance; + } + + /// + /// Incrementally updates the groups with new messages from the conversation. + /// + /// + /// 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. + /// + /// + /// + /// If the message count exceeds , only the new (delta) messages + /// are processed and appended as new groups. Existing groups and their compaction state (exclusions) + /// are preserved, allowing compaction strategies to build on previous results. + /// + /// + /// If the message count is less than (e.g., after storage compaction + /// replaced messages with summaries), all groups are cleared and rebuilt from scratch. + /// + /// + /// If the message count equals , no work is performed. + /// + /// + public void Update(IList allMessages) + { + if (allMessages.Count == this.ProcessedMessageCount) + { + return; // No new messages + } + + if (allMessages.Count < this.ProcessedMessageCount) + { + // Message list shrank (e.g., after storage compaction). Rebuild from scratch. + this.ProcessedMessageCount = 0; + } + + if (this.ProcessedMessageCount == 0) + { + // First update on a manually constructed instance — clear any pre-existing groups + this.Groups.Clear(); + this._currentTurn = 0; + } + + // Process only the delta messages + this.AppendFromMessages(allMessages, this.ProcessedMessageCount); + } + + private void AppendFromMessages(IList messages, int startIndex) { - List groups = []; - int index = 0; - int currentTurn = 0; + int index = startIndex; while (index < messages.Count) { @@ -83,13 +158,13 @@ public static MessageGroups Create(IList messages, Tokenizer? token if (message.Role == ChatRole.System) { // System messages are not part of any turn - groups.Add(CreateGroup(MessageGroupKind.System, [message], tokenizer, turnIndex: null)); + this.Groups.Add(CreateGroup(MessageGroupKind.System, [message], this.Tokenizer, turnIndex: null)); index++; } else if (message.Role == ChatRole.User) { - currentTurn++; - groups.Add(CreateGroup(MessageGroupKind.User, [message], tokenizer, currentTurn)); + this._currentTurn++; + this.Groups.Add(CreateGroup(MessageGroupKind.User, [message], this.Tokenizer, this._currentTurn)); index++; } else if (message.Role == ChatRole.Assistant && HasToolCalls(message)) @@ -104,21 +179,21 @@ public static MessageGroups Create(IList messages, Tokenizer? token index++; } - groups.Add(CreateGroup(MessageGroupKind.ToolCall, groupMessages, tokenizer, currentTurn)); + this.Groups.Add(CreateGroup(MessageGroupKind.ToolCall, groupMessages, this.Tokenizer, this._currentTurn)); } else if (message.Role == ChatRole.Assistant && IsSummaryMessage(message)) { - groups.Add(CreateGroup(MessageGroupKind.Summary, [message], tokenizer, currentTurn)); + this.Groups.Add(CreateGroup(MessageGroupKind.Summary, [message], this.Tokenizer, this._currentTurn)); index++; } else { - groups.Add(CreateGroup(MessageGroupKind.AssistantText, [message], tokenizer, currentTurn)); + this.Groups.Add(CreateGroup(MessageGroupKind.AssistantText, [message], this.Tokenizer, this._currentTurn)); index++; } } - return new MessageGroups(groups, tokenizer); + this.ProcessedMessageCount = messages.Count; } /// diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/PipelineCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/PipelineCompactionStrategy.cs index 0c4af67700..2346d03c32 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/PipelineCompactionStrategy.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/PipelineCompactionStrategy.cs @@ -9,7 +9,7 @@ namespace Microsoft.Agents.AI.Compaction; /// /// A compaction strategy that executes a sequential pipeline of instances -/// against the same . +/// against the same . /// /// /// @@ -28,7 +28,8 @@ 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) + ///// An optional cache for instances. When , a default is created. + public PipelineCompactionStrategy(params IEnumerable strategies/*, IMessageIndexCache? cache = null*/) { this.Strategies = [.. Throw.IfNull(strategies)]; } @@ -58,7 +59,7 @@ public PipelineCompactionStrategy(params IEnumerable strate public int? TargetIncludedGroupCount { get; set; } /// - public async Task CompactAsync(MessageGroups groups, CancellationToken cancellationToken = default) + public async Task CompactAsync(MessageIndex groups, CancellationToken cancellationToken = default) { bool anyCompacted = false; diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/InMemoryChatHistoryProvider.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/InMemoryChatHistoryProvider.cs index 4356c1ac3e..5f03ad424a 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/InMemoryChatHistoryProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/InMemoryChatHistoryProvider.cs @@ -6,7 +6,6 @@ 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; @@ -47,7 +46,6 @@ public InMemoryChatHistoryProvider(InMemoryChatHistoryProviderOptions? options = options?.JsonSerializerOptions); this.ChatReducer = options?.ChatReducer; this.ReducerTriggerEvent = options?.ReducerTriggerEvent ?? InMemoryChatHistoryProviderOptions.ChatReducerTriggerEvent.BeforeMessagesRetrieval; - this.CompactionStrategy = options?.CompactionStrategy; } /// @@ -63,11 +61,6 @@ public InMemoryChatHistoryProvider(InMemoryChatHistoryProviderOptions? options = /// 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. /// @@ -84,20 +77,21 @@ public List GetMessages(AgentSession? session) /// is . public void SetMessages(AgentSession? session, List messages) { - _ = Throw.IfNull(messages); + Throw.IfNull(messages); - var state = this._sessionState.GetOrInitializeState(session); + State state = this._sessionState.GetOrInitializeState(session); state.Messages = messages; } /// protected override async ValueTask> 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-invocation compaction strategy if configured + await this.CompactMessagesAsync(state, cancellationToken).ConfigureAwait(false); } return state.Messages; @@ -106,46 +100,29 @@ protected override async ValueTask> ProvideChatHistoryA /// 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 ?? []); state.Messages.AddRange(allNewMessages); - 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 compaction strategy if configured (pre-write compaction) - if (this.CompactionStrategy is not null) + if (this.ReducerTriggerEvent is InMemoryChatHistoryProviderOptions.ChatReducerTriggerEvent.AfterMessageAdded) { - await CompactMessagesAsync(state.Messages, this.CompactionStrategy, cancellationToken).ConfigureAwait(false); + // Apply pre-write compaction strategy if configured + await this.CompactMessagesAsync(state, 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) + private async Task CompactMessagesAsync(State state, CancellationToken cancellationToken = default) { - ICompactionStrategy strategy = compactionStrategy ?? this.CompactionStrategy - ?? throw new InvalidOperationException("No compaction strategy is configured or provided."); + if (this.ChatReducer is not null) + { + // ChatReducer takes precedence, if configured + state.Messages = [.. await this.ChatReducer.ReduceAsync(state.Messages, cancellationToken).ConfigureAwait(false)]; + return; + } - var state = this._sessionState.GetOrInitializeState(session); - return await CompactMessagesAsync(state.Messages, strategy, cancellationToken).ConfigureAwait(false); + // %%% TODO: CONSIDER COMPACTION } /// diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/InMemoryChatHistoryProviderOptions.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/InMemoryChatHistoryProviderOptions.cs index b30968aa61..5d15bb416b 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/InMemoryChatHistoryProviderOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/InMemoryChatHistoryProviderOptions.cs @@ -74,21 +74,6 @@ 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/ChatClient/ChatClientAgentOptions.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentOptions.cs index 4e1fe61f12..7deff4056c 100644 --- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentOptions.cs @@ -59,10 +59,6 @@ public sealed class ChatClientAgentOptions /// 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; } diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientExtensions.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientExtensions.cs index 8b83830afc..a8d57ec3d0 100644 --- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientExtensions.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.Diagnostics; using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Compaction; using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/CompactingChatClient.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/CompactingChatClient.cs deleted file mode 100644 index 8e94b840ea..0000000000 --- a/dotnet/src/Microsoft.Agents.AI/ChatClient/CompactingChatClient.cs +++ /dev/null @@ -1,75 +0,0 @@ -// 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/CompactingChatClient.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactingChatClient.cs new file mode 100644 index 0000000000..c7fbf66115 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactingChatClient.cs @@ -0,0 +1,133 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Compaction; + +/// +/// 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 `FunctionInvokingChatClient` 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; + private readonly ProviderSessionState _sessionState; + + /// + /// 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); + this._sessionState = new ProviderSessionState( + _ => new State(), + Convert.ToBase64String(BitConverter.GetBytes(compactionStrategy.GetHashCode())), + AgentJsonUtilities.DefaultOptions); + } + + /// + public override async Task GetResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + CancellationToken cancellationToken = default) + { + IEnumerable 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) + { + IEnumerable compactedMessages = await this.ApplyCompactionAsync(messages, cancellationToken).ConfigureAwait(false); + await foreach (var update in base.GetStreamingResponseAsync(compactedMessages, options, cancellationToken).ConfigureAwait(false)) + { + yield return update; + } + } + + /// + public override object? GetService(Type serviceType, object? serviceKey = null) + { + Throw.IfNull(serviceType); + + return + serviceKey is null && serviceType.IsInstanceOfType(typeof(ICompactionStrategy)) ? + this._compactionStrategy : + base.GetService(serviceType, serviceKey); + } + + private async Task> ApplyCompactionAsync( + IEnumerable messages, CancellationToken cancellationToken) + { + List messageList = messages as List ?? [.. messages]; // %%% TODO - LIST COPY + + AgentRunContext? currentAgentContext = AIAgent.CurrentRunContext; + if (currentAgentContext is null || + currentAgentContext.Session is null) + { + // No session available — no reason to compact + return messages; + } + + State state = this._sessionState.GetOrInitializeState(currentAgentContext.Session); + + MessageIndex messageIndex; + if (state.MessageIndex.Count > 0) + { + // Update existing index + messageIndex = new(state.MessageIndex); + messageIndex.Update(messageList); + } + else + { + // First pass — initialize message index state + messageIndex = MessageIndex.Create(messageList); + } + + // Apply compaction + bool wasCompacted = await this._compactionStrategy.CompactAsync(messageIndex, cancellationToken).ConfigureAwait(false); + + if (wasCompacted) + { + state.MessageIndex = [.. messageIndex.Groups]; // %%% TODO - LIST COPY + } + + return wasCompacted ? messageIndex.GetIncludedMessages() : messageList; + } + + /// + /// Represents the state of a stored in the . + /// + public sealed class State + { + /// + /// Gets or sets the message index. + /// + [JsonPropertyName("messages")] + public List MessageIndex { get; set; } = []; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/SummarizationCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/SummarizationCompactionStrategy.cs index 3255d3f998..51c5b4e224 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/SummarizationCompactionStrategy.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/SummarizationCompactionStrategy.cs @@ -58,7 +58,7 @@ public SummarizationCompactionStrategy(IChatClient chatClient, int maxGroupsBefo public string SummarizationPrompt { get; } /// - public async Task CompactAsync(MessageGroups groups, CancellationToken cancellationToken = default) + public async Task CompactAsync(MessageIndex groups, CancellationToken cancellationToken = default) { int includedCount = groups.IncludedGroupCount; if (includedCount <= this.MaxGroupsBeforeSummary) diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/TruncationCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/TruncationCompactionStrategy.cs index dbeef544cd..47d729be57 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/TruncationCompactionStrategy.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/TruncationCompactionStrategy.cs @@ -44,7 +44,7 @@ public TruncationCompactionStrategy(int maxGroups, bool preserveSystemMessages = public bool PreserveSystemMessages { get; } /// - public Task CompactAsync(MessageGroups groups, CancellationToken cancellationToken = default) + public Task CompactAsync(MessageIndex groups, CancellationToken cancellationToken = default) { int includedCount = groups.IncludedGroupCount; if (includedCount <= this.MaxGroups) diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/InMemoryChatHistoryProviderCompactionTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/InMemoryChatHistoryProviderCompactionTests.cs index ae02b85779..d9155e90f0 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/InMemoryChatHistoryProviderCompactionTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/InMemoryChatHistoryProviderCompactionTests.cs @@ -1,268 +1,269 @@ // 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); - } -} +// %%% SAVE - RE-ANALYZE +//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/MessageIndexTests.cs similarity index 89% rename from dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/MessageGroupsTests.cs rename to dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/MessageIndexTests.cs index c3dfedd208..5ded224c52 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/MessageGroupsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/MessageIndexTests.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System.Collections.Generic; using Microsoft.Agents.AI.Compaction; @@ -7,9 +7,9 @@ namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction; /// -/// Contains tests for the class. +/// Contains tests for the class. /// -public class MessageGroupsTests +public class MessageIndexTests { [Fact] public void Create_EmptyList_ReturnsEmptyGroups() @@ -18,7 +18,7 @@ public void Create_EmptyList_ReturnsEmptyGroups() List messages = []; // Act - MessageGroups groups = MessageGroups.Create(messages); + MessageIndex groups = MessageIndex.Create(messages); // Assert Assert.Empty(groups.Groups); @@ -34,7 +34,7 @@ public void Create_SystemMessage_CreatesSystemGroup() ]; // Act - MessageGroups groups = MessageGroups.Create(messages); + MessageIndex groups = MessageIndex.Create(messages); // Assert Assert.Single(groups.Groups); @@ -52,7 +52,7 @@ public void Create_UserMessage_CreatesUserGroup() ]; // Act - MessageGroups groups = MessageGroups.Create(messages); + MessageIndex groups = MessageIndex.Create(messages); // Assert Assert.Single(groups.Groups); @@ -69,7 +69,7 @@ public void Create_AssistantTextMessage_CreatesAssistantTextGroup() ]; // Act - MessageGroups groups = MessageGroups.Create(messages); + MessageIndex groups = MessageIndex.Create(messages); // Assert Assert.Single(groups.Groups); @@ -86,7 +86,7 @@ public void Create_ToolCallWithResults_CreatesAtomicToolCallGroup() List messages = [assistantMessage, toolResult]; // Act - MessageGroups groups = MessageGroups.Create(messages); + MessageIndex groups = MessageIndex.Create(messages); // Assert Assert.Single(groups.Groups); @@ -109,7 +109,7 @@ public void Create_MixedConversation_GroupsCorrectly() List messages = [systemMsg, userMsg, assistantToolCall, toolResult, assistantText]; // Act - MessageGroups groups = MessageGroups.Create(messages); + MessageIndex groups = MessageIndex.Create(messages); // Assert Assert.Equal(4, groups.Groups.Count); @@ -134,7 +134,7 @@ public void Create_MultipleToolResults_GroupsAllWithAssistant() List messages = [assistantToolCall, toolResult1, toolResult2]; // Act - MessageGroups groups = MessageGroups.Create(messages); + MessageIndex groups = MessageIndex.Create(messages); // Assert Assert.Single(groups.Groups); @@ -150,7 +150,7 @@ public void GetIncludedMessages_ExcludesMarkedGroups() ChatMessage msg2 = new(ChatRole.Assistant, "Response"); ChatMessage msg3 = new(ChatRole.User, "Second"); - MessageGroups groups = MessageGroups.Create([msg1, msg2, msg3]); + MessageIndex groups = MessageIndex.Create([msg1, msg2, msg3]); groups.Groups[1].IsExcluded = true; // Act @@ -169,7 +169,7 @@ public void GetAllMessages_IncludesExcludedGroups() ChatMessage msg1 = new(ChatRole.User, "First"); ChatMessage msg2 = new(ChatRole.Assistant, "Response"); - MessageGroups groups = MessageGroups.Create([msg1, msg2]); + MessageIndex groups = MessageIndex.Create([msg1, msg2]); groups.Groups[0].IsExcluded = true; // Act @@ -183,7 +183,7 @@ public void GetAllMessages_IncludesExcludedGroups() public void IncludedGroupCount_ReflectsExclusions() { // Arrange - MessageGroups groups = MessageGroups.Create( + MessageIndex groups = MessageIndex.Create( [ new ChatMessage(ChatRole.User, "A"), new ChatMessage(ChatRole.Assistant, "B"), @@ -207,7 +207,7 @@ public void Create_SummaryMessage_CreatesSummaryGroup() List messages = [summaryMessage]; // Act - MessageGroups groups = MessageGroups.Create(messages); + MessageIndex groups = MessageIndex.Create(messages); // Assert Assert.Single(groups.Groups); @@ -227,7 +227,7 @@ public void Create_SummaryAmongOtherMessages_GroupsCorrectly() List messages = [systemMsg, summaryMsg, userMsg]; // Act - MessageGroups groups = MessageGroups.Create(messages); + MessageIndex groups = MessageIndex.Create(messages); // Assert Assert.Equal(3, groups.Groups.Count); @@ -264,7 +264,7 @@ public void MessageGroup_MessagesAreImmutable() public void Create_ComputesByteCount_Utf8() { // Arrange — "Hello" is 5 UTF-8 bytes - MessageGroups groups = MessageGroups.Create([new ChatMessage(ChatRole.User, "Hello")]); + MessageIndex groups = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]); // Assert Assert.Equal(5, groups.Groups[0].ByteCount); @@ -274,7 +274,7 @@ public void Create_ComputesByteCount_Utf8() 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é")]); + MessageIndex groups = MessageIndex.Create([new ChatMessage(ChatRole.User, "café")]); // Assert Assert.Equal(5, groups.Groups[0].ByteCount); @@ -286,7 +286,7 @@ 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]); + MessageIndex groups = MessageIndex.Create([assistantMsg, toolResult]); // Assert — single ToolCall group with 2 messages Assert.Single(groups.Groups); @@ -298,7 +298,7 @@ public void Create_ComputesByteCount_MultipleMessagesInGroup() 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!")]); + MessageIndex groups = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello world test data!")]); // Assert Assert.Equal(22, groups.Groups[0].ByteCount); @@ -311,7 +311,7 @@ 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]); + MessageIndex groups = MessageIndex.Create([msg, tool]); // Assert Assert.Equal(2, groups.Groups[0].MessageCount); @@ -323,7 +323,7 @@ public void Create_NullText_HasZeroCounts() public void TotalAggregates_SumAllGroups() { // Arrange - MessageGroups groups = MessageGroups.Create( + MessageIndex groups = MessageIndex.Create( [ new ChatMessage(ChatRole.User, "AAAA"), // 4 bytes new ChatMessage(ChatRole.Assistant, "BBBB"), // 4 bytes @@ -342,7 +342,7 @@ public void TotalAggregates_SumAllGroups() public void IncludedAggregates_ExcludeMarkedGroups() { // Arrange - MessageGroups groups = MessageGroups.Create( + MessageIndex groups = MessageIndex.Create( [ new ChatMessage(ChatRole.User, "AAAA"), // 4 bytes new ChatMessage(ChatRole.Assistant, "BBBB"), // 4 bytes @@ -369,7 +369,7 @@ public void ToolCallGroup_AggregatesAcrossMessages() ChatMessage assistantMsg = new(ChatRole.Assistant, [new FunctionCallContent("call1", "fn")]); ChatMessage toolResult = new(ChatRole.Tool, "OK"); - MessageGroups groups = MessageGroups.Create([assistantMsg, toolResult]); + MessageIndex groups = MessageIndex.Create([assistantMsg, toolResult]); // Assert — single group with 2 messages Assert.Single(groups.Groups); @@ -383,7 +383,7 @@ public void ToolCallGroup_AggregatesAcrossMessages() public void Create_AssignsTurnIndices_SingleTurn() { // Arrange — System (no turn), User + Assistant = turn 1 - MessageGroups groups = MessageGroups.Create( + MessageIndex groups = MessageIndex.Create( [ new ChatMessage(ChatRole.System, "You are helpful."), new ChatMessage(ChatRole.User, "Hello"), @@ -402,7 +402,7 @@ public void Create_AssignsTurnIndices_SingleTurn() public void Create_AssignsTurnIndices_MultiTurn() { // Arrange — 3 user turns - MessageGroups groups = MessageGroups.Create( + MessageIndex groups = MessageIndex.Create( [ new ChatMessage(ChatRole.System, "System prompt."), new ChatMessage(ChatRole.User, "Q1"), @@ -429,7 +429,7 @@ public void Create_TurnSpansToolCallGroups() ChatMessage assistantToolCall = new(ChatRole.Assistant, [new FunctionCallContent("call1", "get_weather")]); ChatMessage toolResult = new(ChatRole.Tool, "Sunny"); - MessageGroups groups = MessageGroups.Create( + MessageIndex groups = MessageIndex.Create( [ new ChatMessage(ChatRole.User, "What's the weather?"), assistantToolCall, @@ -449,7 +449,7 @@ public void Create_TurnSpansToolCallGroups() public void GetTurnGroups_ReturnsGroupsForSpecificTurn() { // Arrange - MessageGroups groups = MessageGroups.Create( + MessageIndex groups = MessageIndex.Create( [ new ChatMessage(ChatRole.System, "System."), new ChatMessage(ChatRole.User, "Q1"), @@ -475,7 +475,7 @@ public void GetTurnGroups_ReturnsGroupsForSpecificTurn() public void IncludedTurnCount_ReflectsExclusions() { // Arrange — 2 turns, exclude all groups in turn 1 - MessageGroups groups = MessageGroups.Create( + MessageIndex groups = MessageIndex.Create( [ new ChatMessage(ChatRole.User, "Q1"), new ChatMessage(ChatRole.Assistant, "A1"), @@ -495,7 +495,7 @@ public void IncludedTurnCount_ReflectsExclusions() public void TotalTurnCount_ZeroWhenNoUserMessages() { // Arrange — only system messages - MessageGroups groups = MessageGroups.Create( + MessageIndex groups = MessageIndex.Create( [ new ChatMessage(ChatRole.System, "System."), ]); @@ -509,7 +509,7 @@ public void TotalTurnCount_ZeroWhenNoUserMessages() public void IncludedTurnCount_PartialExclusion_StillCountsTurn() { // Arrange — turn 1 has 2 groups, only one excluded - MessageGroups groups = MessageGroups.Create( + MessageIndex groups = MessageIndex.Create( [ new ChatMessage(ChatRole.User, "Q1"), new ChatMessage(ChatRole.Assistant, "A1"), diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/PipelineCompactionStrategyTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/PipelineCompactionStrategyTests.cs index eb5cda0997..c6a842dea4 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/PipelineCompactionStrategyTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/PipelineCompactionStrategyTests.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System.Collections.Generic; using System.Threading; @@ -20,17 +20,17 @@ public async Task CompactAsync_ExecutesAllStrategiesInOrder() // Arrange List executionOrder = []; Mock strategy1 = new(); - strategy1.Setup(s => s.CompactAsync(It.IsAny(), It.IsAny())) + 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())) + 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")]); + PipelineCompactionStrategy pipeline = new([strategy1.Object, strategy2.Object]); + MessageIndex groups = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]); // Act await pipeline.CompactAsync(groups); @@ -44,11 +44,11 @@ public async Task CompactAsync_ReturnsFalse_WhenNoStrategyCompacts() { // Arrange Mock strategy1 = new(); - strategy1.Setup(s => s.CompactAsync(It.IsAny(), It.IsAny())) + 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")]); + PipelineCompactionStrategy pipeline = new([strategy1.Object]); + MessageIndex groups = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]); // Act bool result = await pipeline.CompactAsync(groups); @@ -62,15 +62,15 @@ public async Task CompactAsync_ReturnsTrue_WhenAnyStrategyCompacts() { // Arrange Mock strategy1 = new(); - strategy1.Setup(s => s.CompactAsync(It.IsAny(), It.IsAny())) + strategy1.Setup(s => s.CompactAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(false); Mock strategy2 = new(); - strategy2.Setup(s => s.CompactAsync(It.IsAny(), It.IsAny())) + 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")]); + PipelineCompactionStrategy pipeline = new([strategy1.Object, strategy2.Object]); + MessageIndex groups = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]); // Act bool result = await pipeline.CompactAsync(groups); @@ -84,22 +84,22 @@ public async Task CompactAsync_ContinuesAfterFirstCompaction_WhenEarlyStopDisabl { // Arrange Mock strategy1 = new(); - strategy1.Setup(s => s.CompactAsync(It.IsAny(), It.IsAny())) + strategy1.Setup(s => s.CompactAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(true); Mock strategy2 = new(); - strategy2.Setup(s => s.CompactAsync(It.IsAny(), It.IsAny())) + 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")]); + PipelineCompactionStrategy pipeline = new([strategy1.Object, strategy2.Object]); + MessageIndex groups = MessageIndex.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); + strategy1.Verify(s => s.CompactAsync(It.IsAny(), It.IsAny()), Times.Once); + strategy2.Verify(s => s.CompactAsync(It.IsAny(), It.IsAny()), Times.Once); } [Fact] @@ -107,8 +107,8 @@ 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, _) => + strategy1.Setup(s => s.CompactAsync(It.IsAny(), It.IsAny())) + .Callback((groups, _) => { // Exclude the first group to bring count down groups.Groups[0].IsExcluded = true; @@ -116,16 +116,16 @@ public async Task CompactAsync_StopsEarly_WhenTargetReached() .ReturnsAsync(true); Mock strategy2 = new(); - strategy2.Setup(s => s.CompactAsync(It.IsAny(), It.IsAny())) + strategy2.Setup(s => s.CompactAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(false); - PipelineCompactionStrategy pipeline = new(strategy1.Object, strategy2.Object) + PipelineCompactionStrategy pipeline = new([strategy1.Object, strategy2.Object]) { EarlyStop = true, TargetIncludedGroupCount = 2, }; - MessageGroups groups = MessageGroups.Create( + MessageIndex groups = MessageIndex.Create( [ new ChatMessage(ChatRole.User, "First"), new ChatMessage(ChatRole.Assistant, "Response"), @@ -137,8 +137,8 @@ public async Task CompactAsync_StopsEarly_WhenTargetReached() // 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); + strategy1.Verify(s => s.CompactAsync(It.IsAny(), It.IsAny()), Times.Once); + strategy2.Verify(s => s.CompactAsync(It.IsAny(), It.IsAny()), Times.Never); } [Fact] @@ -146,20 +146,20 @@ 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())) + strategy1.Setup(s => s.CompactAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(false); Mock strategy2 = new(); - strategy2.Setup(s => s.CompactAsync(It.IsAny(), It.IsAny())) + strategy2.Setup(s => s.CompactAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(false); - PipelineCompactionStrategy pipeline = new(strategy1.Object, strategy2.Object) + PipelineCompactionStrategy pipeline = new([strategy1.Object, strategy2.Object]) { EarlyStop = true, TargetIncludedGroupCount = 1, }; - MessageGroups groups = MessageGroups.Create( + MessageIndex groups = MessageIndex.Create( [ new ChatMessage(ChatRole.User, "First"), new ChatMessage(ChatRole.User, "Second"), @@ -170,8 +170,8 @@ public async Task CompactAsync_DoesNotStopEarly_WhenTargetNotReached() 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); + strategy1.Verify(s => s.CompactAsync(It.IsAny(), It.IsAny()), Times.Once); + strategy2.Verify(s => s.CompactAsync(It.IsAny(), It.IsAny()), Times.Once); } [Fact] @@ -179,27 +179,27 @@ public async Task CompactAsync_EarlyStopIgnored_WhenNoTargetSet() { // Arrange Mock strategy1 = new(); - strategy1.Setup(s => s.CompactAsync(It.IsAny(), It.IsAny())) + strategy1.Setup(s => s.CompactAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(true); Mock strategy2 = new(); - strategy2.Setup(s => s.CompactAsync(It.IsAny(), It.IsAny())) + strategy2.Setup(s => s.CompactAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(false); - PipelineCompactionStrategy pipeline = new(strategy1.Object, strategy2.Object) + PipelineCompactionStrategy pipeline = new([strategy1.Object, strategy2.Object]) { EarlyStop = true, // TargetIncludedGroupCount is null }; - MessageGroups groups = MessageGroups.Create([new ChatMessage(ChatRole.User, "Hello")]); + MessageIndex groups = MessageIndex.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); + strategy1.Verify(s => s.CompactAsync(It.IsAny(), It.IsAny()), Times.Once); + strategy2.Verify(s => s.CompactAsync(It.IsAny(), It.IsAny()), Times.Once); } [Fact] @@ -207,8 +207,8 @@ 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, _) => + phase1.Setup(s => s.CompactAsync(It.IsAny(), It.IsAny())) + .Callback((groups, _) => { int excluded = 0; foreach (MessageGroup group in groups.Groups) @@ -223,8 +223,8 @@ public async Task CompactAsync_ComposesStrategies_EndToEnd() .ReturnsAsync(true); Mock phase2 = new(); - phase2.Setup(s => s.CompactAsync(It.IsAny(), It.IsAny())) - .Callback((groups, _) => + phase2.Setup(s => s.CompactAsync(It.IsAny(), It.IsAny())) + .Callback((groups, _) => { int excluded = 0; foreach (MessageGroup group in groups.Groups) @@ -238,9 +238,9 @@ public async Task CompactAsync_ComposesStrategies_EndToEnd() }) .ReturnsAsync(true); - PipelineCompactionStrategy pipeline = new(phase1.Object, phase2.Object); + PipelineCompactionStrategy pipeline = new([phase1.Object, phase2.Object]); - MessageGroups groups = MessageGroups.Create( + MessageIndex groups = MessageIndex.Create( [ new ChatMessage(ChatRole.System, "You are helpful."), new ChatMessage(ChatRole.User, "Q1"), @@ -262,8 +262,8 @@ public async Task CompactAsync_ComposesStrategies_EndToEnd() 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); + phase1.Verify(s => s.CompactAsync(It.IsAny(), It.IsAny()), Times.Once); + phase2.Verify(s => s.CompactAsync(It.IsAny(), It.IsAny()), Times.Once); } [Fact] @@ -271,7 +271,7 @@ public async Task CompactAsync_EmptyPipeline_ReturnsFalseAsync() { // Arrange PipelineCompactionStrategy pipeline = new(new List()); - MessageGroups groups = MessageGroups.Create([new ChatMessage(ChatRole.User, "Hello")]); + MessageIndex groups = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]); // Act bool result = await pipeline.CompactAsync(groups); diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/TruncationCompactionStrategyTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/TruncationCompactionStrategyTests.cs index 41855884a9..7b24966728 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/TruncationCompactionStrategyTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/TruncationCompactionStrategyTests.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System.Threading.Tasks; using Microsoft.Agents.AI.Compaction; @@ -16,7 +16,7 @@ public async Task CompactAsync_BelowLimit_ReturnsFalseAsync() { // Arrange TruncationCompactionStrategy strategy = new(maxGroups: 5); - MessageGroups groups = MessageGroups.Create( + MessageIndex groups = MessageIndex.Create( [ new ChatMessage(ChatRole.User, "Hello"), new ChatMessage(ChatRole.Assistant, "Hi!"), @@ -35,7 +35,7 @@ public async Task CompactAsync_AtLimit_ReturnsFalseAsync() { // Arrange TruncationCompactionStrategy strategy = new(maxGroups: 2); - MessageGroups groups = MessageGroups.Create( + MessageIndex groups = MessageIndex.Create( [ new ChatMessage(ChatRole.User, "Hello"), new ChatMessage(ChatRole.Assistant, "Hi!"), @@ -58,7 +58,7 @@ public async Task CompactAsync_ExceedsLimit_ExcludesOldestGroupsAsync() ChatMessage msg3 = new(ChatRole.User, "Second"); ChatMessage msg4 = new(ChatRole.Assistant, "Response 2"); - MessageGroups groups = MessageGroups.Create([msg1, msg2, msg3, msg4]); + MessageIndex groups = MessageIndex.Create([msg1, msg2, msg3, msg4]); // Act bool result = await strategy.CompactAsync(groups); @@ -82,7 +82,7 @@ public async Task CompactAsync_PreservesSystemMessages_WhenEnabledAsync() ChatMessage msg2 = new(ChatRole.Assistant, "Response 1"); ChatMessage msg3 = new(ChatRole.User, "Second"); - MessageGroups groups = MessageGroups.Create([systemMsg, msg1, msg2, msg3]); + MessageIndex groups = MessageIndex.Create([systemMsg, msg1, msg2, msg3]); // Act bool result = await strategy.CompactAsync(groups); @@ -109,7 +109,7 @@ public async Task CompactAsync_DoesNotPreserveSystemMessages_WhenDisabledAsync() ChatMessage msg2 = new(ChatRole.Assistant, "Response"); ChatMessage msg3 = new(ChatRole.User, "Second"); - MessageGroups groups = MessageGroups.Create([systemMsg, msg1, msg2, msg3]); + MessageIndex groups = MessageIndex.Create([systemMsg, msg1, msg2, msg3]); // Act bool result = await strategy.CompactAsync(groups); @@ -133,7 +133,7 @@ public async Task CompactAsync_PreservesToolCallGroupAtomicityAsync() ChatMessage toolResult = new(ChatRole.Tool, "Sunny"); ChatMessage finalResponse = new(ChatRole.User, "Thanks!"); - MessageGroups groups = MessageGroups.Create([assistantToolCall, toolResult, finalResponse]); + MessageIndex groups = MessageIndex.Create([assistantToolCall, toolResult, finalResponse]); // Act bool result = await strategy.CompactAsync(groups); @@ -152,7 +152,7 @@ public async Task CompactAsync_SetsExcludeReasonAsync() { // Arrange TruncationCompactionStrategy strategy = new(maxGroups: 1); - MessageGroups groups = MessageGroups.Create( + MessageIndex groups = MessageIndex.Create( [ new ChatMessage(ChatRole.User, "Old"), new ChatMessage(ChatRole.User, "New"), @@ -171,7 +171,7 @@ public async Task CompactAsync_SkipsAlreadyExcludedGroupsAsync() { // Arrange TruncationCompactionStrategy strategy = new(maxGroups: 1); - MessageGroups groups = MessageGroups.Create( + MessageIndex groups = MessageIndex.Create( [ new ChatMessage(ChatRole.User, "Already excluded"), new ChatMessage(ChatRole.User, "Included 1"), From eb8406214eb295627fa3cee2be4d7fbc3583459d Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Thu, 5 Mar 2026 01:48:40 -0800 Subject: [PATCH 03/22] Stable --- .../Program.cs | 13 +- .../Compaction/ICompactionStrategy.cs | 38 --- .../Compaction/PipelineCompactionStrategy.cs | 83 ------ .../InMemoryChatHistoryProviderOptions.cs | 1 - .../Microsoft.Agents.AI.Abstractions.csproj | 1 - .../ChatClient/ChatClientAgentOptions.cs | 8 +- .../Compaction/CompactingChatClient.cs | 15 +- .../Compaction/CompactionStrategy.cs | 107 +++++++ .../Compaction/CompactionTrigger.cs | 10 + .../Compaction/CompactionTriggers.cs | 100 +++++++ .../Compaction/MessageGroup.cs | 0 .../Compaction/MessageGroupKind.cs | 0 .../Compaction/MessageIndex.cs | 0 .../Compaction/PipelineCompactionStrategy.cs | 59 ++++ .../SlidingWindowCompactionStrategy.cs | 97 ++++++ .../SummarizationCompactionStrategy.cs | 104 +++++-- .../ToolResultCompactionStrategy.cs | 117 ++++++++ .../TruncationCompactionStrategy.cs | 82 ++--- .../Microsoft.Agents.AI.csproj | 6 +- .../PipelineCompactionStrategyTests.cs | 282 ------------------ .../Compaction/CompactionTriggersTests.cs | 155 ++++++++++ ...emoryChatHistoryProviderCompactionTests.cs | 2 +- .../Compaction/MessageIndexTests.cs | 4 +- .../PipelineCompactionStrategyTests.cs | 191 ++++++++++++ .../SlidingWindowCompactionStrategyTests.cs | 160 ++++++++++ .../ToolResultCompactionStrategyTests.cs | 195 ++++++++++++ .../TruncationCompactionStrategyTests.cs | 148 +++++---- 27 files changed, 1426 insertions(+), 552 deletions(-) delete mode 100644 dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ICompactionStrategy.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/PipelineCompactionStrategy.cs create mode 100644 dotnet/src/Microsoft.Agents.AI/Compaction/CompactionStrategy.cs create mode 100644 dotnet/src/Microsoft.Agents.AI/Compaction/CompactionTrigger.cs create mode 100644 dotnet/src/Microsoft.Agents.AI/Compaction/CompactionTriggers.cs rename dotnet/src/{Microsoft.Agents.AI.Abstractions => Microsoft.Agents.AI}/Compaction/MessageGroup.cs (100%) rename dotnet/src/{Microsoft.Agents.AI.Abstractions => Microsoft.Agents.AI}/Compaction/MessageGroupKind.cs (100%) rename dotnet/src/{Microsoft.Agents.AI.Abstractions => Microsoft.Agents.AI}/Compaction/MessageIndex.cs (100%) create mode 100644 dotnet/src/Microsoft.Agents.AI/Compaction/PipelineCompactionStrategy.cs create mode 100644 dotnet/src/Microsoft.Agents.AI/Compaction/SlidingWindowCompactionStrategy.cs create mode 100644 dotnet/src/Microsoft.Agents.AI/Compaction/ToolResultCompactionStrategy.cs delete mode 100644 dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/PipelineCompactionStrategyTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionTriggersTests.cs rename dotnet/tests/{Microsoft.Agents.AI.Abstractions.UnitTests => Microsoft.Agents.AI.UnitTests}/Compaction/InMemoryChatHistoryProviderCompactionTests.cs (99%) rename dotnet/tests/{Microsoft.Agents.AI.Abstractions.UnitTests => Microsoft.Agents.AI.UnitTests}/Compaction/MessageIndexTests.cs (99%) create mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/PipelineCompactionStrategyTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/SlidingWindowCompactionStrategyTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/ToolResultCompactionStrategyTests.cs diff --git a/dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/Program.cs b/dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/Program.cs index 82a38c538c..0a57de892e 100644 --- a/dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/Program.cs +++ b/dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/Program.cs @@ -39,23 +39,18 @@ static string LookupPrice([Description("The product name to look up.")] string p }; // Configure the compaction pipeline with one of each strategy, ordered least to most aggressive. -//const int MaxTokens = 512; -//const int MaxTurns = 4; -const int MaxGroups = 2; - PipelineCompactionStrategy compactionPipeline = new(// 1. Gentle: collapse old tool-call groups into short summaries like "[Tool calls: LookupPrice]" - //new ToolResultCompactionStrategy(MaxTokens, preserveRecentGroups: 2), + new ToolResultCompactionStrategy(CompactionTriggers.TokensExceed(0x200)), // 2. Moderate: use an LLM to summarize older conversation spans into a concise message - new SummarizationCompactionStrategy(summarizerChatClient, MaxGroups) + new SummarizationCompactionStrategy(summarizerChatClient, CompactionTriggers.TokensExceed(0x500)), // 3. Aggressive: keep only the last N user turns and their responses - //new SlidingWindowCompactionStrategy(MaxTurns), + new SlidingWindowCompactionStrategy(maximumTurns: 4), // 4. Emergency: drop oldest groups until under the token budget - //new TruncationCompactionStrategy(MaxGroups) - ); + new TruncationCompactionStrategy(CompactionTriggers.TokensExceed(0x8000))); // Create the agent with an in-memory chat history provider whose reducer is the compaction pipeline. AIAgent agent = diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ICompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ICompactionStrategy.cs deleted file mode 100644 index c894dd0d19..0000000000 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ICompactionStrategy.cs +++ /dev/null @@ -1,38 +0,0 @@ -// 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(MessageIndex groups, CancellationToken cancellationToken = default); -} diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/PipelineCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/PipelineCompactionStrategy.cs deleted file mode 100644 index 2346d03c32..0000000000 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/PipelineCompactionStrategy.cs +++ /dev/null @@ -1,83 +0,0 @@ -// 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. - ///// An optional cache for instances. When , a default is created. - public PipelineCompactionStrategy(params IEnumerable strategies/*, IMessageIndexCache? cache = null*/) - { - 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(MessageIndex 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/InMemoryChatHistoryProviderOptions.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/InMemoryChatHistoryProviderOptions.cs index 5d15bb416b..ba24f55ded 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/InMemoryChatHistoryProviderOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/InMemoryChatHistoryProviderOptions.cs @@ -3,7 +3,6 @@ using System; using System.Collections.Generic; using System.Text.Json; -using Microsoft.Agents.AI.Compaction; using Microsoft.Extensions.AI; namespace Microsoft.Agents.AI; 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 b097386b14..e31093e174 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,7 +29,6 @@ - diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentOptions.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentOptions.cs index 7deff4056c..28b5643a30 100644 --- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentOptions.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System.Collections.Generic; using Microsoft.Agents.AI.Compaction; @@ -47,7 +47,7 @@ public sealed class ChatClientAgentOptions public IEnumerable? AIContextProviders { get; set; } /// - /// Gets or sets the to use for in-run context compaction. + /// Gets or sets the to use for in-run context compaction. /// /// /// @@ -57,10 +57,10 @@ public sealed class ChatClientAgentOptions /// /// /// The strategy organizes messages into atomic groups (preserving tool-call/result pairings) - /// before applying compaction logic. See for details. + /// before applying compaction logic. See for details. /// /// - public ICompactionStrategy? CompactionStrategy { get; set; } + public CompactionStrategy? CompactionStrategy { get; set; } /// /// Gets or sets a value indicating whether to use the provided instance as is, diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactingChatClient.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactingChatClient.cs index c7fbf66115..90e97bc611 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactingChatClient.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactingChatClient.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Runtime.CompilerServices; using System.Text.Json.Serialization; using System.Threading; @@ -12,7 +13,7 @@ namespace Microsoft.Agents.AI.Compaction; /// -/// A delegating that applies an to the message list +/// A delegating that applies an to the message list /// before each call to the inner chat client. /// /// @@ -28,7 +29,7 @@ namespace Microsoft.Agents.AI.Compaction; /// internal sealed class CompactingChatClient : DelegatingChatClient { - private readonly ICompactionStrategy _compactionStrategy; + private readonly CompactionStrategy _compactionStrategy; private readonly ProviderSessionState _sessionState; /// @@ -36,7 +37,7 @@ internal sealed class CompactingChatClient : DelegatingChatClient /// /// The inner chat client to delegate to. /// The compaction strategy to apply before each call. - public CompactingChatClient(IChatClient innerClient, ICompactionStrategy compactionStrategy) + public CompactingChatClient(IChatClient innerClient, CompactionStrategy compactionStrategy) : base(innerClient) { this._compactionStrategy = Throw.IfNull(compactionStrategy); @@ -75,7 +76,7 @@ public override async IAsyncEnumerable GetStreamingResponseA Throw.IfNull(serviceType); return - serviceKey is null && serviceType.IsInstanceOfType(typeof(ICompactionStrategy)) ? + serviceKey is null && serviceType.IsInstanceOfType(typeof(CompactionStrategy)) ? this._compactionStrategy : base.GetService(serviceType, serviceKey); } @@ -108,8 +109,12 @@ private async Task> ApplyCompactionAsync( messageIndex = MessageIndex.Create(messageList); } - // Apply compaction + // Apply compaction + Stopwatch stopwatch = Stopwatch.StartNew(); bool wasCompacted = await this._compactionStrategy.CompactAsync(messageIndex, cancellationToken).ConfigureAwait(false); + stopwatch.Stop(); + + Debug.WriteLine($"COMPACTION: {wasCompacted} - {stopwatch.ElapsedMilliseconds}ms"); if (wasCompacted) { diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionStrategy.cs new file mode 100644 index 0000000000..2bc1e7abd8 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionStrategy.cs @@ -0,0 +1,107 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Compaction; + +/// +/// Base class for strategies that compact 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). +/// +/// +/// Every strategy requires a that determines whether compaction should +/// proceed based on current metrics (token count, message count, turn count, etc.). +/// The base class evaluates this trigger at the start of and skips compaction when +/// the trigger returns . +/// +/// +/// 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 +/// via . +/// +/// +public abstract class CompactionStrategy +{ + /// + /// Initializes a new instance of the class. + /// + /// + /// The that determines whether compaction should proceed. + /// + protected CompactionStrategy(CompactionTrigger trigger) + { + this.Trigger = Throw.IfNull(trigger); + } + + /// + /// Gets the trigger predicate that controls when compaction proceeds. + /// + protected CompactionTrigger Trigger { get; } + + /// + /// Evaluates the and, when it fires, delegates to + /// and reports compaction metrics. + /// + /// The message index 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. + public async Task CompactAsync(MessageIndex index, CancellationToken cancellationToken = default) + { + if (!this.Trigger(index)) + { + return false; + } + + int beforeTokens = index.IncludedTokenCount; + int beforeGroups = index.IncludedGroupCount; + int beforeMessages = index.IncludedMessageCount; + + Stopwatch stopwatch = Stopwatch.StartNew(); + + bool compacted = await this.ApplyCompactionAsync(index, cancellationToken).ConfigureAwait(false); + + stopwatch.Stop(); + + if (compacted) + { + Debug.WriteLine( + $""" + COMPACTION: {this.GetType().Name} + Duration {stopwatch.ElapsedMilliseconds}ms + Messages {beforeMessages} => {index.IncludedMessageCount} + Groups {beforeGroups} => {index.IncludedGroupCount} + Tokens {beforeTokens} => {index.IncludedTokenCount} + """); + } + + return compacted; + } + + /// + /// Applies the strategy-specific compaction logic to the specified message index. + /// + /// + /// This method is called by only when the + /// returns . Implementations do not need to evaluate the trigger or + /// report metrics — the base class handles both. + /// + /// The message index to compact. The strategy mutates this collection in place. + /// The to monitor for cancellation requests. + /// A task whose result is if any compaction was performed, otherwise. + protected abstract Task ApplyCompactionAsync(MessageIndex index, CancellationToken cancellationToken); +} diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionTrigger.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionTrigger.cs new file mode 100644 index 0000000000..2ca28e2005 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionTrigger.cs @@ -0,0 +1,10 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.Compaction; + +/// +/// A predicate that evaluates whether compaction should proceed based on current metrics. +/// +/// The current message index with group, token, message, and turn metrics. +/// if compaction should proceed; to skip. +public delegate bool CompactionTrigger(MessageIndex index); diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionTriggers.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionTriggers.cs new file mode 100644 index 0000000000..d3eb350ca6 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionTriggers.cs @@ -0,0 +1,100 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Linq; + +namespace Microsoft.Agents.AI.Compaction; + +/// +/// Provides factory methods for common predicates. +/// +/// +/// These triggers evaluate included (non-excluded) metrics from the . +/// Combine triggers with or for compound conditions, +/// or write a custom lambda for full flexibility. +/// +public static class CompactionTriggers +{ + /// + /// Always triger compaction, regardless of the message index state. + /// + public static readonly CompactionTrigger Always = + _ => true; + + /// + /// Creates a trigger that fires when the included token count exceeds the specified maximum. + /// + /// The token threshold. Compaction proceeds when included tokens exceed this value. + /// A that evaluates included token count. + public static CompactionTrigger TokensExceed(int maxTokens) => + index => index.IncludedTokenCount > maxTokens; + + /// + /// Creates a trigger that fires when the included message count exceeds the specified maximum. + /// + /// The message threshold. Compaction proceeds when included messages exceed this value. + /// A that evaluates included message count. + public static CompactionTrigger MessagesExceed(int maxMessages) => + index => index.IncludedMessageCount > maxMessages; + + /// + /// Creates a trigger that fires when the included user turn count exceeds the specified maximum. + /// + /// The turn threshold. Compaction proceeds when included turns exceed this value. + /// A that evaluates included turn count. + public static CompactionTrigger TurnsExceed(int maxTurns) => + index => index.IncludedTurnCount > maxTurns; + + /// + /// Creates a trigger that fires when the included group count exceeds the specified maximum. + /// + /// The group threshold. Compaction proceeds when included groups exceed this value. + /// A that evaluates included group count. + public static CompactionTrigger GroupsExceed(int maxGroups) => + index => index.IncludedGroupCount > maxGroups; + + /// + /// Creates a trigger that fires when the included message index contains at least one + /// non-excluded group. + /// + /// A that evaluates included tool call presence. + public static CompactionTrigger HasToolCalls() => + index => index.Groups.Any(g => !g.IsExcluded && g.Kind == MessageGroupKind.ToolCall); + + /// + /// Creates a compound trigger that fires only when all of the specified triggers fire. + /// + /// The triggers to combine with logical AND. + /// A that requires all conditions to be met. + public static CompactionTrigger All(params CompactionTrigger[] triggers) => + index => + { + for (int i = 0; i < triggers.Length; i++) + { + if (!triggers[i](index)) + { + return false; + } + } + + return true; + }; + + /// + /// Creates a compound trigger that fires when any of the specified triggers fire. + /// + /// The triggers to combine with logical OR. + /// A that requires at least one condition to be met. + public static CompactionTrigger Any(params CompactionTrigger[] triggers) => + index => + { + for (int i = 0; i < triggers.Length; i++) + { + if (triggers[i](index)) + { + return true; + } + } + + return false; + }; +} diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/MessageGroup.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/MessageGroup.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/MessageGroup.cs rename to dotnet/src/Microsoft.Agents.AI/Compaction/MessageGroup.cs diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/MessageGroupKind.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/MessageGroupKind.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/MessageGroupKind.cs rename to dotnet/src/Microsoft.Agents.AI/Compaction/MessageGroupKind.cs diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/MessageIndex.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/MessageIndex.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/MessageIndex.cs rename to dotnet/src/Microsoft.Agents.AI/Compaction/MessageIndex.cs diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/PipelineCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/PipelineCompactionStrategy.cs new file mode 100644 index 0000000000..d48b3e7bad --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/PipelineCompactionStrategy.cs @@ -0,0 +1,59 @@ +// 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. +/// +/// +/// The pipeline's own is evaluated first. If it returns +/// , none of the child strategies are executed. Each child strategy also +/// evaluates its own trigger independently. +/// +/// +public sealed class PipelineCompactionStrategy : CompactionStrategy +{ + /// + /// Initializes a new instance of the class. + /// + /// The ordered sequence of strategies to execute. + public PipelineCompactionStrategy(params IEnumerable strategies) + : base(CompactionTriggers.Always) + { + this.Strategies = [.. Throw.IfNull(strategies)]; + } + + /// + /// Gets the ordered list of strategies in this pipeline. + /// + public IReadOnlyList Strategies { get; } + + /// + protected override async Task ApplyCompactionAsync(MessageIndex index, CancellationToken cancellationToken) + { + bool anyCompacted = false; + + foreach (CompactionStrategy strategy in this.Strategies) + { + bool compacted = await strategy.CompactAsync(index, cancellationToken).ConfigureAwait(false); + + if (compacted) + { + anyCompacted = true; + } + } + + return anyCompacted; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/SlidingWindowCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/SlidingWindowCompactionStrategy.cs new file mode 100644 index 0000000000..accf974963 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/SlidingWindowCompactionStrategy.cs @@ -0,0 +1,97 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Agents.AI.Compaction; + +/// +/// A compaction strategy that keeps only the most recent user turns and their +/// associated response groups, removing older turns to bound conversation length. +/// +/// +/// +/// This strategy always preserves system messages. It identifies user turns in the +/// conversation (via ) and keeps the last +/// turns along with all response groups (assistant replies, +/// tool call groups) that belong to each kept turn. +/// +/// +/// The predicate controls when compaction proceeds. +/// When , a default trigger of +/// with is used. +/// +/// +/// 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. +/// +/// +public sealed class SlidingWindowCompactionStrategy : CompactionStrategy +{ + /// + /// The default maximum number of user turns to retain before compaction occurs. This default is a reasonable starting point + /// for many conversations, but should be tuned based on the expected conversation length and token budget. + /// + public const int DefaultMaximumTurns = 32; + + /// + /// Initializes a new instance of the class. + /// + /// + /// The maximum number of user turns to keep. Older turns and their associated responses are removed. + /// + public SlidingWindowCompactionStrategy(int maximumTurns = DefaultMaximumTurns) + : base(CompactionTriggers.TurnsExceed(maximumTurns)) + { + this.MaxTurns = maximumTurns; + } + + /// + /// Gets the maximum number of user turns to retain after compaction. + /// + public int MaxTurns { get; } + + /// + protected override Task ApplyCompactionAsync(MessageIndex index, CancellationToken cancellationToken) + { + // Collect distinct included turn indices in order + List includedTurns = []; + foreach (MessageGroup group in index.Groups) + { + if (!group.IsExcluded && group.TurnIndex is int turnIndex && !includedTurns.Contains(turnIndex)) + { + includedTurns.Add(turnIndex); + } + } + + if (includedTurns.Count <= this.MaxTurns) + { + return Task.FromResult(false); + } + + // Determine which turn indices to exclude (oldest) + int turnsToRemove = includedTurns.Count - this.MaxTurns; + HashSet excludedTurnIndices = [.. includedTurns.Take(turnsToRemove)]; + + bool compacted = false; + for (int i = 0; i < index.Groups.Count; i++) + { + MessageGroup group = index.Groups[i]; + if (group.IsExcluded || group.Kind == MessageGroupKind.System) + { + continue; + } + + if (group.TurnIndex is int ti && excludedTurnIndices.Contains(ti)) + { + group.IsExcluded = true; + group.ExcludeReason = $"Excluded by {nameof(SlidingWindowCompactionStrategy)}"; + compacted = true; + } + } + + return Task.FromResult(compacted); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/SummarizationCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/SummarizationCompactionStrategy.cs index 51c5b4e224..c995783d23 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/SummarizationCompactionStrategy.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/SummarizationCompactionStrategy.cs @@ -1,5 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. +using System; +using System.Linq; using System.Text; using System.Threading; using System.Threading.Tasks; @@ -9,36 +11,62 @@ namespace Microsoft.Agents.AI.Compaction; /// -/// A compaction strategy that summarizes older message groups using an , -/// replacing them with a single summary message. +/// 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. /// /// /// -/// 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. +/// This strategy protects system messages and the most recent +/// non-system groups. All older groups are collected and sent to the +/// for summarization. The resulting summary replaces those messages as a single assistant message +/// with . /// /// -/// System message groups are always preserved and never included in summarization. +/// The predicate controls when compaction proceeds. +/// When , the strategy compacts whenever there are groups older than the preserve window. +/// Use for common trigger conditions such as token thresholds. /// /// -public sealed class SummarizationCompactionStrategy : ICompactionStrategy +public sealed class SummarizationCompactionStrategy : CompactionStrategy { - 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."; + /// + /// The default summarization prompt used when none is provided. + /// + 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. + """; /// /// 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) + /// The to use for generating summaries. A smaller, faster model is recommended. + /// + /// The that controls when compaction proceeds. + /// + /// + /// The number of most-recent non-system message groups to protect from summarization. + /// Defaults to 4, preserving the current and recent exchanges. + /// + /// + /// An optional custom system prompt for the summarization LLM call. When , + /// is used. + /// + public SummarizationCompactionStrategy( + IChatClient chatClient, + CompactionTrigger trigger, + int preserveRecentGroups = 4, + string? summarizationPrompt = null) + : base(trigger) { this.ChatClient = Throw.IfNull(chatClient); - this.MaxGroupsBeforeSummary = maxGroupsBeforeSummary; + this.PreserveRecentGroups = preserveRecentGroups; this.SummarizationPrompt = summarizationPrompt ?? DefaultSummarizationPrompt; } @@ -48,9 +76,9 @@ public SummarizationCompactionStrategy(IChatClient chatClient, int maxGroupsBefo public IChatClient ChatClient { get; } /// - /// Gets the maximum number of included groups allowed before summarization is triggered. + /// Gets the number of most-recent non-system groups to protect from summarization. /// - public int MaxGroupsBeforeSummary { get; } + public int PreserveRecentGroups { get; } /// /// Gets the prompt used when requesting summaries from the chat client. @@ -58,25 +86,35 @@ public SummarizationCompactionStrategy(IChatClient chatClient, int maxGroupsBefo public string SummarizationPrompt { get; } /// - public async Task CompactAsync(MessageIndex groups, CancellationToken cancellationToken = default) + protected override async Task ApplyCompactionAsync(MessageIndex index, CancellationToken cancellationToken) { - int includedCount = groups.IncludedGroupCount; - if (includedCount <= this.MaxGroupsBeforeSummary) + // Count non-system, non-excluded groups to determine which are protected + int nonSystemIncludedCount = 0; + for (int i = 0; i < index.Groups.Count; i++) { - return false; + MessageGroup group = index.Groups[i]; + if (!group.IsExcluded && group.Kind != MessageGroupKind.System) + { + nonSystemIncludedCount++; + } } - // Determine how many groups to summarize (keep the most recent MaxGroupsBeforeSummary groups) - int groupsToSummarize = includedCount - this.MaxGroupsBeforeSummary; + int protectedFromEnd = Math.Min(this.PreserveRecentGroups, nonSystemIncludedCount); + int groupsToSummarize = nonSystemIncludedCount - protectedFromEnd; + + if (groupsToSummarize <= 0) + { + return false; + } // 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++) + for (int i = 0; i < index.Groups.Count && summarized < groupsToSummarize; i++) { - MessageGroup group = groups.Groups[i]; + MessageGroup group = index.Groups[i]; if (group.IsExcluded || group.Kind == MessageGroupKind.System) { continue; @@ -98,7 +136,7 @@ public async Task CompactAsync(MessageIndex groups, CancellationToken canc } group.IsExcluded = true; - group.ExcludeReason = "Summarized by SummarizationCompactionStrategy"; + group.ExcludeReason = $"Summarized by {nameof(SummarizationCompactionStrategy)}"; summarized++; } @@ -111,23 +149,27 @@ public async Task CompactAsync(MessageIndex groups, CancellationToken canc ChatResponse response = await this.ChatClient.GetResponseAsync( [ new ChatMessage(ChatRole.System, this.SummarizationPrompt), + .. index.Groups + .Where(g => !g.IsExcluded && g.Kind == MessageGroupKind.System) + .SelectMany(g => g.Messages), new ChatMessage(ChatRole.User, conversationText.ToString()), + new ChatMessage(ChatRole.User, "Summarize the conversation above concisely."), ], cancellationToken: cancellationToken).ConfigureAwait(false); - string summaryText = response.Text ?? string.Empty; + string summaryText = string.IsNullOrWhiteSpace(response.Text) ? "[Summary unavailable]" : response.Text; // Insert a summary group at the position of the first summarized group - ChatMessage summaryMessage = new(ChatRole.Assistant, $"[Summary of earlier conversation]: {summaryText}"); + ChatMessage summaryMessage = new(ChatRole.Assistant, $"[Summary]\n{summaryText}"); (summaryMessage.AdditionalProperties ??= [])[MessageGroup.SummaryPropertyKey] = true; if (insertIndex >= 0) { - groups.InsertGroup(insertIndex, MessageGroupKind.Summary, [summaryMessage]); + index.InsertGroup(insertIndex, MessageGroupKind.Summary, [summaryMessage]); } else { - groups.AddGroup(MessageGroupKind.Summary, [summaryMessage]); + index.AddGroup(MessageGroupKind.Summary, [summaryMessage]); } return true; diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/ToolResultCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/ToolResultCompactionStrategy.cs new file mode 100644 index 0000000000..c37d7b7596 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/ToolResultCompactionStrategy.cs @@ -0,0 +1,117 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Compaction; + +/// +/// 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. +/// +/// +/// +/// This is the gentlest compaction strategy — it does not remove any user messages or +/// plain assistant responses. It only targets +/// groups outside the protected recent window, replacing each multi-message group +/// (assistant call + tool results) with a single assistant message like +/// [Tool calls: get_weather, search_docs]. +/// +/// +/// The predicate controls when compaction proceeds. +/// When , a default compound trigger of +/// AND +/// is used. +/// +/// +public sealed class ToolResultCompactionStrategy : CompactionStrategy +{ + /// + /// The default number of most-recent non-system groups to protect from collapsing. + /// + public const int DefaultPreserveRecentGroups = 2; + + /// + /// Initializes a new instance of the class. + /// + /// + /// The that controls when compaction proceeds. + /// + /// + /// The number of most-recent non-system message groups to protect from collapsing. + /// Defaults to , ensuring the current turn's tool interactions remain visible. + /// + public ToolResultCompactionStrategy(CompactionTrigger trigger, int preserveRecentGroups = DefaultPreserveRecentGroups) + : base(trigger) + { + this.PreserveRecentGroups = preserveRecentGroups; + } + + /// + /// Gets the number of most-recent non-system groups to protect from collapsing. + /// + public int PreserveRecentGroups { get; } + + /// + protected override Task ApplyCompactionAsync(MessageIndex index, CancellationToken cancellationToken) + { + // Identify protected groups: the N most-recent non-system, non-excluded groups + List nonSystemIncludedIndices = []; + for (int i = 0; i < index.Groups.Count; i++) + { + MessageGroup group = index.Groups[i]; + if (!group.IsExcluded && group.Kind != MessageGroupKind.System) + { + nonSystemIncludedIndices.Add(i); + } + } + + int protectedStart = Math.Max(0, nonSystemIncludedIndices.Count - this.PreserveRecentGroups); + HashSet protectedGroupIndices = []; + for (int i = protectedStart; i < nonSystemIncludedIndices.Count; i++) + { + protectedGroupIndices.Add(nonSystemIncludedIndices[i]); + } + + // Process from end to start so insertions don't shift earlier indices + bool compacted = false; + for (int i = index.Groups.Count - 1; i >= 0; i--) + { + MessageGroup group = index.Groups[i]; + if (group.IsExcluded || group.Kind != MessageGroupKind.ToolCall || protectedGroupIndices.Contains(i)) + { + continue; + } + + // Extract tool names from FunctionCallContent + List toolNames = []; + foreach (ChatMessage message in group.Messages) + { + if (message.Contents is not null) + { + foreach (AIContent content in message.Contents) + { + if (content is FunctionCallContent fcc) + { + toolNames.Add(fcc.Name); + } + } + } + } + + // Exclude the original group and insert a collapsed replacement + group.IsExcluded = true; + group.ExcludeReason = $"Collapsed by {nameof(ToolResultCompactionStrategy)}"; + + string summary = $"[Tool calls: {string.Join(", ", toolNames)}]"; + index.InsertGroup(i + 1, MessageGroupKind.AssistantText, [new ChatMessage(ChatRole.Assistant, summary)], group.TurnIndex); + + compacted = true; + } + + return Task.FromResult(compacted); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/TruncationCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/TruncationCompactionStrategy.cs index 47d729be57..5bd9630b77 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/TruncationCompactionStrategy.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/TruncationCompactionStrategy.cs @@ -6,72 +6,82 @@ 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. +/// A compaction strategy that removes the oldest non-system message groups, +/// keeping the most recent groups up to . /// /// /// -/// 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 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. /// /// -/// This strategy respects atomic group preservation — tool call groups (assistant message + tool results) -/// are always kept or excluded together. +/// The controls when compaction proceeds. +/// Use for common trigger conditions such as token or group thresholds. /// /// -public sealed class TruncationCompactionStrategy : ICompactionStrategy +public sealed class TruncationCompactionStrategy : CompactionStrategy { /// - /// Initializes a new instance of the class. + /// The default number of most-recent non-system groups to protect from collapsing. /// - /// 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; - } + public const int DefaultPreserveRecentGroups = 32; /// - /// Gets the maximum number of message groups to retain after compaction. + /// Initializes a new instance of the class. /// - public int MaxGroups { get; } + /// + /// The that controls when compaction proceeds. + /// + /// + /// The minimum number of most-recent non-system message groups to keep. + /// Defaults to 1 so that at least the latest exchange is always preserved. + /// + public TruncationCompactionStrategy(CompactionTrigger trigger, int preserveRecentGroups = DefaultPreserveRecentGroups) + : base(trigger) + { + this.PreserveRecentGroups = preserveRecentGroups; + } /// - /// Gets a value indicating whether system message groups are preserved regardless of their position in the conversation. + /// Gets the minimum number of most-recent non-system message groups to retain after compaction. /// - public bool PreserveSystemMessages { get; } + public int PreserveRecentGroups { get; } /// - public Task CompactAsync(MessageIndex groups, CancellationToken cancellationToken = default) + protected override Task ApplyCompactionAsync(MessageIndex index, CancellationToken cancellationToken) { - int includedCount = groups.IncludedGroupCount; - if (includedCount <= this.MaxGroups) + // Count removable (non-system, non-excluded) groups + int removableCount = 0; + for (int i = 0; i < index.Groups.Count; i++) { - return Task.FromResult(false); + MessageGroup group = index.Groups[i]; + if (!group.IsExcluded && group.Kind != MessageGroupKind.System) + { + removableCount++; + } } - int excessCount = includedCount - this.MaxGroups; - bool compacted = false; + int maxRemovable = removableCount - this.PreserveRecentGroups; + if (maxRemovable <= 0) + { + return Task.FromResult(false); + } // Exclude oldest non-system groups first (iterate from the beginning) - for (int i = 0; i < groups.Groups.Count && excessCount > 0; i++) + bool compacted = false; + int removed = 0; + for (int i = 0; i < index.Groups.Count && removed < maxRemovable; i++) { - MessageGroup group = groups.Groups[i]; - if (group.IsExcluded) - { - continue; - } - - if (this.PreserveSystemMessages && group.Kind == MessageGroupKind.System) + MessageGroup group = index.Groups[i]; + if (group.IsExcluded || group.Kind == MessageGroupKind.System) { continue; } group.IsExcluded = true; - group.ExcludeReason = "Truncated by TruncationCompactionStrategy"; - excessCount--; + group.ExcludeReason = $"Truncated by {nameof(TruncationCompactionStrategy)}"; + removed++; compacted = true; } diff --git a/dotnet/src/Microsoft.Agents.AI/Microsoft.Agents.AI.csproj b/dotnet/src/Microsoft.Agents.AI/Microsoft.Agents.AI.csproj index f036812900..93b228d29e 100644 --- a/dotnet/src/Microsoft.Agents.AI/Microsoft.Agents.AI.csproj +++ b/dotnet/src/Microsoft.Agents.AI/Microsoft.Agents.AI.csproj @@ -18,10 +18,14 @@ + + + + @@ -36,7 +40,7 @@ - + diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/PipelineCompactionStrategyTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/PipelineCompactionStrategyTests.cs deleted file mode 100644 index c6a842dea4..0000000000 --- a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/PipelineCompactionStrategyTests.cs +++ /dev/null @@ -1,282 +0,0 @@ -// 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]); - MessageIndex groups = MessageIndex.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]); - MessageIndex groups = MessageIndex.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]); - MessageIndex groups = MessageIndex.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]); - MessageIndex groups = MessageIndex.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, - }; - - MessageIndex groups = MessageIndex.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, - }; - - MessageIndex groups = MessageIndex.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 - }; - - MessageIndex groups = MessageIndex.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]); - - MessageIndex groups = MessageIndex.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()); - MessageIndex groups = MessageIndex.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/CompactionTriggersTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionTriggersTests.cs new file mode 100644 index 0000000000..cd85ff3f01 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionTriggersTests.cs @@ -0,0 +1,155 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI.Compaction; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.UnitTests.Compaction; + +/// +/// Contains tests for and . +/// +public class CompactionTriggersTests +{ + [Fact] + public void TokensExceed_ReturnsTrueWhenAboveThreshold() + { + // Arrange — use a long message to guarantee tokens > 0 + CompactionTrigger trigger = CompactionTriggers.TokensExceed(0); + MessageIndex index = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello world")]); + + // Act & Assert + Assert.True(trigger(index)); + } + + [Fact] + public void TokensExceed_ReturnsFalseWhenBelowThreshold() + { + CompactionTrigger trigger = CompactionTriggers.TokensExceed(999_999); + MessageIndex index = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hi")]); + + Assert.False(trigger(index)); + } + + [Fact] + public void MessagesExceed_ReturnsExpectedResult() + { + CompactionTrigger trigger = CompactionTriggers.MessagesExceed(2); + MessageIndex small = MessageIndex.Create( + [ + new ChatMessage(ChatRole.User, "A"), + new ChatMessage(ChatRole.User, "B"), + ]); + MessageIndex large = MessageIndex.Create( + [ + new ChatMessage(ChatRole.User, "A"), + new ChatMessage(ChatRole.User, "B"), + new ChatMessage(ChatRole.User, "C"), + ]); + + Assert.False(trigger(small)); + Assert.True(trigger(large)); + } + + [Fact] + public void TurnsExceed_ReturnsExpectedResult() + { + CompactionTrigger trigger = CompactionTriggers.TurnsExceed(1); + MessageIndex oneTurn = MessageIndex.Create( + [ + new ChatMessage(ChatRole.User, "Q1"), + new ChatMessage(ChatRole.Assistant, "A1"), + ]); + MessageIndex twoTurns = MessageIndex.Create( + [ + new ChatMessage(ChatRole.User, "Q1"), + new ChatMessage(ChatRole.Assistant, "A1"), + new ChatMessage(ChatRole.User, "Q2"), + ]); + + Assert.False(trigger(oneTurn)); + Assert.True(trigger(twoTurns)); + } + + [Fact] + public void GroupsExceed_ReturnsExpectedResult() + { + CompactionTrigger trigger = CompactionTriggers.GroupsExceed(2); + MessageIndex index = MessageIndex.Create( + [ + new ChatMessage(ChatRole.User, "A"), + new ChatMessage(ChatRole.Assistant, "B"), + new ChatMessage(ChatRole.User, "C"), + ]); + + Assert.True(trigger(index)); + } + + [Fact] + public void HasToolCalls_ReturnsTrueWhenToolCallGroupExists() + { + CompactionTrigger trigger = CompactionTriggers.HasToolCalls(); + MessageIndex index = MessageIndex.Create( + [ + new ChatMessage(ChatRole.User, "Q1"), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "fn")]), + new ChatMessage(ChatRole.Tool, "result"), + ]); + + Assert.True(trigger(index)); + } + + [Fact] + public void HasToolCalls_ReturnsFalseWhenNoToolCallGroup() + { + CompactionTrigger trigger = CompactionTriggers.HasToolCalls(); + MessageIndex index = MessageIndex.Create( + [ + new ChatMessage(ChatRole.User, "Hello"), + new ChatMessage(ChatRole.Assistant, "Hi!"), + ]); + + Assert.False(trigger(index)); + } + + [Fact] + public void All_RequiresAllConditions() + { + CompactionTrigger trigger = CompactionTriggers.All( + CompactionTriggers.TokensExceed(0), + CompactionTriggers.MessagesExceed(5)); + + MessageIndex small = MessageIndex.Create([new ChatMessage(ChatRole.User, "A")]); + + // Tokens > 0 is true, but messages > 5 is false + Assert.False(trigger(small)); + } + + [Fact] + public void Any_RequiresAtLeastOneCondition() + { + CompactionTrigger trigger = CompactionTriggers.Any( + CompactionTriggers.TokensExceed(999_999), + CompactionTriggers.MessagesExceed(0)); + + MessageIndex index = MessageIndex.Create([new ChatMessage(ChatRole.User, "A")]); + + // Tokens not exceeded, but messages > 0 is true + Assert.True(trigger(index)); + } + + [Fact] + public void All_EmptyTriggers_ReturnsTrue() + { + CompactionTrigger trigger = CompactionTriggers.All(); + MessageIndex index = MessageIndex.Create([new ChatMessage(ChatRole.User, "A")]); + Assert.True(trigger(index)); + } + + [Fact] + public void Any_EmptyTriggers_ReturnsFalse() + { + CompactionTrigger trigger = CompactionTriggers.Any(); + MessageIndex index = MessageIndex.Create([new ChatMessage(ChatRole.User, "A")]); + Assert.False(trigger(index)); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/InMemoryChatHistoryProviderCompactionTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/InMemoryChatHistoryProviderCompactionTests.cs similarity index 99% rename from dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/InMemoryChatHistoryProviderCompactionTests.cs rename to dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/InMemoryChatHistoryProviderCompactionTests.cs index d9155e90f0..95cfb88aaa 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/InMemoryChatHistoryProviderCompactionTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/InMemoryChatHistoryProviderCompactionTests.cs @@ -8,7 +8,7 @@ //using Microsoft.Extensions.AI; //using Moq; -//namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction; +//namespace Microsoft.Agents.AI.UnitTests.Compaction; ///// ///// Contains tests for the compaction integration with . diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/MessageIndexTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/MessageIndexTests.cs similarity index 99% rename from dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/MessageIndexTests.cs rename to dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/MessageIndexTests.cs index 5ded224c52..bb84fdd930 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/MessageIndexTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/MessageIndexTests.cs @@ -1,10 +1,10 @@ -// Copyright (c) Microsoft. All rights reserved. +// 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; +namespace Microsoft.Agents.AI.UnitTests.Compaction; /// /// Contains tests for the class. diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/PipelineCompactionStrategyTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/PipelineCompactionStrategyTests.cs new file mode 100644 index 0000000000..ddeb9129d6 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/PipelineCompactionStrategyTests.cs @@ -0,0 +1,191 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Threading; +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 PipelineCompactionStrategyTests +{ + [Fact] + public async Task CompactAsync_ExecutesAllStrategiesInOrderAsync() + { + // Arrange + List executionOrder = []; + TestCompactionStrategy strategy1 = new( + _ => + { + executionOrder.Add("first"); + return false; + }); + + TestCompactionStrategy strategy2 = new( + _ => + { + executionOrder.Add("second"); + return false; + }); + + PipelineCompactionStrategy pipeline = new(strategy1, strategy2); + MessageIndex groups = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]); + + // Act + await pipeline.CompactAsync(groups); + + // Assert + Assert.Equal(["first", "second"], executionOrder); + } + + [Fact] + public async Task CompactAsync_ReturnsFalse_WhenNoStrategyCompactsAsync() + { + // Arrange + TestCompactionStrategy strategy1 = new(_ => false); + + PipelineCompactionStrategy pipeline = new(strategy1); + MessageIndex groups = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]); + + // Act + bool result = await pipeline.CompactAsync(groups); + + // Assert + Assert.False(result); + } + + [Fact] + public async Task CompactAsync_ReturnsTrue_WhenAnyStrategyCompactsAsync() + { + // Arrange + TestCompactionStrategy strategy1 = new(_ => false); + TestCompactionStrategy strategy2 = new(_ => true); + + PipelineCompactionStrategy pipeline = new(strategy1, strategy2); + MessageIndex groups = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]); + + // Act + bool result = await pipeline.CompactAsync(groups); + + // Assert + Assert.True(result); + } + + [Fact] + public async Task CompactAsync_ContinuesAfterFirstCompaction_WhenEarlyStopDisabledAsync() + { + // Arrange + TestCompactionStrategy strategy1 = new(_ => true); + TestCompactionStrategy strategy2 = new(_ => false); + + PipelineCompactionStrategy pipeline = new(strategy1, strategy2); + MessageIndex groups = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]); + + // Act + await pipeline.CompactAsync(groups); + + // Assert — both strategies were called + Assert.Equal(1, strategy1.ApplyCallCount); + Assert.Equal(1, strategy2.ApplyCallCount); + } + + [Fact] + public async Task CompactAsync_ComposesStrategies_EndToEndAsync() + { + // Arrange — pipeline: first exclude oldest 2 non-system groups, then exclude 2 more + static void ExcludeOldest2(MessageIndex index) + { + int excluded = 0; + foreach (MessageGroup group in index.Groups) + { + if (!group.IsExcluded && group.Kind != MessageGroupKind.System && excluded < 2) + { + group.IsExcluded = true; + excluded++; + } + } + } + + TestCompactionStrategy phase1 = new( + index => + { + ExcludeOldest2(index); + return true; + }); + + TestCompactionStrategy phase2 = new( + index => + { + ExcludeOldest2(index); + return true; + }); + + PipelineCompactionStrategy pipeline = new(phase1, phase2); + + MessageIndex groups = MessageIndex.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); + + Assert.Equal(1, phase1.ApplyCallCount); + Assert.Equal(1, phase2.ApplyCallCount); + } + + [Fact] + public async Task CompactAsync_EmptyPipeline_ReturnsFalseAsync() + { + // Arrange + PipelineCompactionStrategy pipeline = new(new List()); + MessageIndex groups = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]); + + // Act + bool result = await pipeline.CompactAsync(groups); + + // Assert + Assert.False(result); + } + + /// + /// A simple test implementation of that delegates to a synchronous callback. + /// + private sealed class TestCompactionStrategy : CompactionStrategy + { + private readonly Func _applyFunc; + + public TestCompactionStrategy(Func applyFunc) + : base(CompactionTriggers.Always) + { + this._applyFunc = applyFunc; + } + + public int ApplyCallCount { get; private set; } + + protected override Task ApplyCompactionAsync(MessageIndex index, CancellationToken cancellationToken) + { + this.ApplyCallCount++; + return Task.FromResult(this._applyFunc(index)); + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/SlidingWindowCompactionStrategyTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/SlidingWindowCompactionStrategyTests.cs new file mode 100644 index 0000000000..8a16aa3f21 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/SlidingWindowCompactionStrategyTests.cs @@ -0,0 +1,160 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +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 SlidingWindowCompactionStrategyTests +{ + [Fact] + public async Task CompactAsync_BelowMaxTurns_ReturnsFalseAsync() + { + // Arrange + SlidingWindowCompactionStrategy strategy = new(maximumTurns: 3); + MessageIndex groups = MessageIndex.Create( + [ + new ChatMessage(ChatRole.User, "Q1"), + new ChatMessage(ChatRole.Assistant, "A1"), + new ChatMessage(ChatRole.User, "Q2"), + new ChatMessage(ChatRole.Assistant, "A2"), + ]); + + // Act + bool result = await strategy.CompactAsync(groups); + + // Assert + Assert.False(result); + } + + [Fact] + public async Task CompactAsync_ExceedsMaxTurns_ExcludesOldestTurnsAsync() + { + // Arrange — keep 2 turns, conversation has 3 + SlidingWindowCompactionStrategy strategy = new(maximumTurns: 2); + MessageIndex groups = MessageIndex.Create( + [ + 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"), + new ChatMessage(ChatRole.Assistant, "A3"), + ]); + + // Act + bool result = await strategy.CompactAsync(groups); + + // Assert + Assert.True(result); + // Turn 1 (Q1 + A1) should be excluded + Assert.True(groups.Groups[0].IsExcluded); + Assert.True(groups.Groups[1].IsExcluded); + // Turn 2 and 3 should remain + Assert.False(groups.Groups[2].IsExcluded); + Assert.False(groups.Groups[3].IsExcluded); + Assert.False(groups.Groups[4].IsExcluded); + Assert.False(groups.Groups[5].IsExcluded); + } + + [Fact] + public async Task CompactAsync_PreservesSystemMessagesAsync() + { + // Arrange + SlidingWindowCompactionStrategy strategy = new(maximumTurns: 1); + MessageIndex groups = MessageIndex.Create( + [ + new ChatMessage(ChatRole.System, "You are helpful."), + new ChatMessage(ChatRole.User, "Q1"), + new ChatMessage(ChatRole.Assistant, "A1"), + new ChatMessage(ChatRole.User, "Q2"), + ]); + + // Act + bool result = await strategy.CompactAsync(groups); + + // Assert + Assert.True(result); + Assert.False(groups.Groups[0].IsExcluded); // System preserved + Assert.True(groups.Groups[1].IsExcluded); // Turn 1 excluded + Assert.True(groups.Groups[2].IsExcluded); // Turn 1 response excluded + Assert.False(groups.Groups[3].IsExcluded); // Turn 2 kept + } + + [Fact] + public async Task CompactAsync_PreservesToolCallGroupsInKeptTurnsAsync() + { + // Arrange + SlidingWindowCompactionStrategy strategy = new(maximumTurns: 1); + MessageIndex groups = MessageIndex.Create( + [ + new ChatMessage(ChatRole.User, "Q1"), + new ChatMessage(ChatRole.Assistant, "A1"), + new ChatMessage(ChatRole.User, "Q2"), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", "search")]), + new ChatMessage(ChatRole.Tool, "Results"), + ]); + + // Act + bool result = await strategy.CompactAsync(groups); + + // Assert + Assert.True(result); + // Turn 1 excluded + Assert.True(groups.Groups[0].IsExcluded); + Assert.True(groups.Groups[1].IsExcluded); + // Turn 2 kept (user + tool call group) + Assert.False(groups.Groups[2].IsExcluded); + Assert.False(groups.Groups[3].IsExcluded); + } + + [Fact] + public async Task CompactAsync_CustomTrigger_OverridesDefaultAsync() + { + // Arrange — custom trigger: only compact when tokens exceed threshold + SlidingWindowCompactionStrategy strategy = new(maximumTurns: 99); + + MessageIndex groups = MessageIndex.Create( + [ + new ChatMessage(ChatRole.User, "Q1"), + new ChatMessage(ChatRole.User, "Q2"), + new ChatMessage(ChatRole.User, "Q3"), + ]); + + // Act — tokens are tiny, trigger not met + bool result = await strategy.CompactAsync(groups); + + // Assert + Assert.False(result); + } + + [Fact] + public async Task CompactAsync_IncludedMessages_ContainOnlyKeptTurnsAsync() + { + // Arrange + SlidingWindowCompactionStrategy strategy = new(maximumTurns: 1); + MessageIndex groups = MessageIndex.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 + await strategy.CompactAsync(groups); + + // Assert + List included = [.. groups.GetIncludedMessages()]; + Assert.Equal(3, included.Count); + Assert.Equal("System", included[0].Text); + Assert.Equal("Q2", included[1].Text); + Assert.Equal("A2", included[2].Text); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/ToolResultCompactionStrategyTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/ToolResultCompactionStrategyTests.cs new file mode 100644 index 0000000000..7fc17e8005 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/ToolResultCompactionStrategyTests.cs @@ -0,0 +1,195 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Linq; +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 ToolResultCompactionStrategyTests +{ + [Fact] + public async Task CompactAsync_TriggerNotMet_ReturnsFalseAsync() + { + // Arrange — trigger requires > 1000 tokens + ToolResultCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(1000)); + + ChatMessage toolCall = new(ChatRole.Assistant, [new FunctionCallContent("call1", "get_weather")]); + ChatMessage toolResult = new(ChatRole.Tool, "Sunny"); + + MessageIndex groups = MessageIndex.Create( + [ + new ChatMessage(ChatRole.User, "What's the weather?"), + toolCall, + toolResult, + ]); + + // Act + bool result = await strategy.CompactAsync(groups); + + // Assert + Assert.False(result); + } + + [Fact] + public async Task CompactAsync_CollapsesOldToolGroupsAsync() + { + // Arrange — always trigger + ToolResultCompactionStrategy strategy = new( + trigger: _ => true, + preserveRecentGroups: 1); + + MessageIndex groups = MessageIndex.Create( + [ + new ChatMessage(ChatRole.User, "Q1"), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", "get_weather")]), + new ChatMessage(ChatRole.Tool, "Sunny and 72°F"), + new ChatMessage(ChatRole.User, "Q2"), + ]); + + // Act + bool result = await strategy.CompactAsync(groups); + + // Assert + Assert.True(result); + + List included = [.. groups.GetIncludedMessages()]; + // Q1 + collapsed tool summary + Q2 + Assert.Equal(3, included.Count); + Assert.Equal("Q1", included[0].Text); + Assert.Contains("[Tool calls: get_weather]", included[1].Text); + Assert.Equal("Q2", included[2].Text); + } + + [Fact] + public async Task CompactAsync_PreservesRecentToolGroupsAsync() + { + // Arrange — protect 2 recent non-system groups (the tool group + Q2) + ToolResultCompactionStrategy strategy = new( + trigger: _ => true, + preserveRecentGroups: 3); + + MessageIndex groups = MessageIndex.Create( + [ + new ChatMessage(ChatRole.User, "Q1"), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", "search")]), + new ChatMessage(ChatRole.Tool, "Results"), + new ChatMessage(ChatRole.User, "Q2"), + ]); + + // Act + bool result = await strategy.CompactAsync(groups); + + // Assert — all groups are in the protected window, nothing to collapse + Assert.False(result); + } + + [Fact] + public async Task CompactAsync_PreservesSystemMessagesAsync() + { + // Arrange + ToolResultCompactionStrategy strategy = new( + trigger: _ => true, + preserveRecentGroups: 1); + + MessageIndex groups = MessageIndex.Create( + [ + new ChatMessage(ChatRole.System, "You are helpful."), + new ChatMessage(ChatRole.User, "Q1"), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", "fn")]), + new ChatMessage(ChatRole.Tool, "result"), + new ChatMessage(ChatRole.User, "Q2"), + ]); + + // Act + await strategy.CompactAsync(groups); + + // Assert + List included = [.. groups.GetIncludedMessages()]; + Assert.Equal("You are helpful.", included[0].Text); + } + + [Fact] + public async Task CompactAsync_ExtractsMultipleToolNamesAsync() + { + // Arrange — assistant calls two tools + ToolResultCompactionStrategy strategy = new( + trigger: _ => true, + preserveRecentGroups: 1); + + ChatMessage multiToolCall = new(ChatRole.Assistant, + [ + new FunctionCallContent("c1", "get_weather"), + new FunctionCallContent("c2", "search_docs"), + ]); + + MessageIndex groups = MessageIndex.Create( + [ + new ChatMessage(ChatRole.User, "Q1"), + multiToolCall, + new ChatMessage(ChatRole.Tool, "Sunny"), + new ChatMessage(ChatRole.Tool, "Found 3 docs"), + new ChatMessage(ChatRole.User, "Q2"), + ]); + + // Act + await strategy.CompactAsync(groups); + + // Assert + List included = [.. groups.GetIncludedMessages()]; + string collapsed = included[1].Text!; + Assert.Contains("get_weather", collapsed); + Assert.Contains("search_docs", collapsed); + } + + [Fact] + public async Task CompactAsync_NoToolGroups_ReturnsFalseAsync() + { + // Arrange — trigger fires but no tool groups to collapse + ToolResultCompactionStrategy strategy = new( + trigger: _ => true, + preserveRecentGroups: 0); + + MessageIndex groups = MessageIndex.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_CompoundTrigger_RequiresTokensAndToolCallsAsync() + { + // Arrange — compound: tokens > 0 AND has tool calls + ToolResultCompactionStrategy strategy = new( + CompactionTriggers.All( + CompactionTriggers.TokensExceed(0), + CompactionTriggers.HasToolCalls()), + preserveRecentGroups: 1); + + MessageIndex groups = MessageIndex.Create( + [ + new ChatMessage(ChatRole.User, "Q1"), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "fn")]), + new ChatMessage(ChatRole.Tool, "result"), + new ChatMessage(ChatRole.User, "Q2"), + ]); + + // Act + bool result = await strategy.CompactAsync(groups); + + // Assert + Assert.True(result); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/TruncationCompactionStrategyTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/TruncationCompactionStrategyTests.cs index 7b24966728..6f246a9987 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/TruncationCompactionStrategyTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/TruncationCompactionStrategyTests.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using System.Linq; using System.Threading.Tasks; using Microsoft.Agents.AI.Compaction; using Microsoft.Extensions.AI; @@ -11,30 +12,36 @@ namespace Microsoft.Agents.AI.UnitTests.Compaction; /// public class TruncationCompactionStrategyTests { + private static readonly CompactionTrigger s_alwaysTrigger = _ => true; + [Fact] - public async Task CompactAsync_BelowLimit_ReturnsFalseAsync() + public async Task CompactAsync_AlwaysTrigger_CompactsToPreserveRecentAsync() { - // Arrange - TruncationCompactionStrategy strategy = new(maxGroups: 5); + // Arrange — always-trigger means always compact + TruncationCompactionStrategy strategy = new(s_alwaysTrigger, preserveRecentGroups: 1); MessageIndex groups = MessageIndex.Create( [ - new ChatMessage(ChatRole.User, "Hello"), - new ChatMessage(ChatRole.Assistant, "Hi!"), + new ChatMessage(ChatRole.User, "First"), + new ChatMessage(ChatRole.Assistant, "Response 1"), + new ChatMessage(ChatRole.User, "Second"), ]); // Act bool result = await strategy.CompactAsync(groups); // Assert - Assert.False(result); - Assert.Equal(2, groups.IncludedGroupCount); + Assert.True(result); + Assert.Equal(1, groups.Groups.Count(g => !g.IsExcluded)); } [Fact] - public async Task CompactAsync_AtLimit_ReturnsFalseAsync() + public async Task CompactAsync_TriggerNotMet_ReturnsFalseAsync() { - // Arrange - TruncationCompactionStrategy strategy = new(maxGroups: 2); + // Arrange — trigger requires > 1000 tokens, conversation is tiny + TruncationCompactionStrategy strategy = new( + preserveRecentGroups: 1, + trigger: CompactionTriggers.TokensExceed(1000)); + MessageIndex groups = MessageIndex.Create( [ new ChatMessage(ChatRole.User, "Hello"), @@ -46,43 +53,50 @@ public async Task CompactAsync_AtLimit_ReturnsFalseAsync() // Assert Assert.False(result); + Assert.Equal(2, groups.IncludedGroupCount); } [Fact] - public async Task CompactAsync_ExceedsLimit_ExcludesOldestGroupsAsync() + public async Task CompactAsync_TriggerMet_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"); + // Arrange — trigger on groups > 2 + TruncationCompactionStrategy strategy = new( + preserveRecentGroups: 1, + trigger: CompactionTriggers.GroupsExceed(2)); - MessageIndex groups = MessageIndex.Create([msg1, msg2, msg3, msg4]); + MessageIndex groups = MessageIndex.Create( + [ + 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 strategy.CompactAsync(groups); // Assert Assert.True(result); - Assert.Equal(2, groups.IncludedGroupCount); + Assert.Equal(1, groups.IncludedGroupCount); + // Oldest 3 excluded, newest 1 kept Assert.True(groups.Groups[0].IsExcluded); Assert.True(groups.Groups[1].IsExcluded); - Assert.False(groups.Groups[2].IsExcluded); + Assert.True(groups.Groups[2].IsExcluded); Assert.False(groups.Groups[3].IsExcluded); } [Fact] - public async Task CompactAsync_PreservesSystemMessages_WhenEnabledAsync() + public async Task CompactAsync_PreservesSystemMessagesAsync() { // 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"); - - MessageIndex groups = MessageIndex.Create([systemMsg, msg1, msg2, msg3]); + TruncationCompactionStrategy strategy = new(s_alwaysTrigger, preserveRecentGroups: 1); + MessageIndex groups = MessageIndex.Create( + [ + new ChatMessage(ChatRole.System, "You are helpful."), + new ChatMessage(ChatRole.User, "First"), + new ChatMessage(ChatRole.Assistant, "Response 1"), + new ChatMessage(ChatRole.User, "Second"), + ]); // Act bool result = await strategy.CompactAsync(groups); @@ -92,34 +106,10 @@ public async Task CompactAsync_PreservesSystemMessages_WhenEnabledAsync() // 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 + // Oldest non-system groups 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"); - - MessageIndex groups = MessageIndex.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); + // Most recent kept Assert.False(groups.Groups[3].IsExcluded); } @@ -127,9 +117,9 @@ public async Task CompactAsync_DoesNotPreserveSystemMessages_WhenDisabledAsync() public async Task CompactAsync_PreservesToolCallGroupAtomicityAsync() { // Arrange - TruncationCompactionStrategy strategy = new(maxGroups: 1); + TruncationCompactionStrategy strategy = new(s_alwaysTrigger, preserveRecentGroups: 1); - ChatMessage assistantToolCall = new(ChatRole.Assistant, [new FunctionCallContent("call1", "get_weather")]); + ChatMessage assistantToolCall= new(ChatRole.Assistant, [new FunctionCallContent("call1", "get_weather")]); ChatMessage toolResult = new(ChatRole.Tool, "Sunny"); ChatMessage finalResponse = new(ChatRole.User, "Thanks!"); @@ -151,7 +141,7 @@ public async Task CompactAsync_PreservesToolCallGroupAtomicityAsync() public async Task CompactAsync_SetsExcludeReasonAsync() { // Arrange - TruncationCompactionStrategy strategy = new(maxGroups: 1); + TruncationCompactionStrategy strategy = new(s_alwaysTrigger, preserveRecentGroups: 1); MessageIndex groups = MessageIndex.Create( [ new ChatMessage(ChatRole.User, "Old"), @@ -170,7 +160,7 @@ public async Task CompactAsync_SetsExcludeReasonAsync() public async Task CompactAsync_SkipsAlreadyExcludedGroupsAsync() { // Arrange - TruncationCompactionStrategy strategy = new(maxGroups: 1); + TruncationCompactionStrategy strategy = new(s_alwaysTrigger, preserveRecentGroups: 1); MessageIndex groups = MessageIndex.Create( [ new ChatMessage(ChatRole.User, "Already excluded"), @@ -188,4 +178,46 @@ public async Task CompactAsync_SkipsAlreadyExcludedGroupsAsync() Assert.True(groups.Groups[1].IsExcluded); // newly excluded Assert.False(groups.Groups[2].IsExcluded); // kept } + + [Fact] + public async Task CompactAsync_PreserveRecentGroups_KeepsMultipleAsync() + { + // Arrange — keep 2 most recent + TruncationCompactionStrategy strategy = new(s_alwaysTrigger, preserveRecentGroups: 2); + MessageIndex groups = MessageIndex.Create( + [ + new ChatMessage(ChatRole.User, "Q1"), + new ChatMessage(ChatRole.Assistant, "A1"), + new ChatMessage(ChatRole.User, "Q2"), + new ChatMessage(ChatRole.Assistant, "A2"), + ]); + + // Act + bool result = await strategy.CompactAsync(groups); + + // Assert + Assert.True(result); + 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_NothingToRemove_ReturnsFalseAsync() + { + // Arrange — preserve 5 but only 2 groups + TruncationCompactionStrategy strategy = new(s_alwaysTrigger, preserveRecentGroups: 5); + MessageIndex groups = MessageIndex.Create( + [ + new ChatMessage(ChatRole.User, "Hello"), + new ChatMessage(ChatRole.Assistant, "Hi!"), + ]); + + // Act + bool result = await strategy.CompactAsync(groups); + + // Assert + Assert.False(result); + } } From 0e8b9b283f108362ebaef10780db2ca00df15f03 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Thu, 5 Mar 2026 02:25:00 -0800 Subject: [PATCH 04/22] Strategies --- .../Compaction/CompactionStrategy.cs | 24 ++++++++++- .../Compaction/CompactionTriggers.cs | 8 ++++ .../SlidingWindowCompactionStrategy.cs | 37 +++++++++------- .../SummarizationCompactionStrategy.cs | 25 +++++++---- .../ToolResultCompactionStrategy.cs | 42 +++++++++++++++---- .../TruncationCompactionStrategy.cs | 16 +++++-- .../TruncationCompactionStrategyTests.cs | 8 ++-- 7 files changed, 122 insertions(+), 38 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionStrategy.cs index 2bc1e7abd8..dc8ece399c 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionStrategy.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionStrategy.cs @@ -23,6 +23,12 @@ namespace Microsoft.Agents.AI.Compaction; /// the trigger returns . /// /// +/// An optional target condition controls when compaction stops. Strategies incrementally exclude +/// groups and re-evaluate the target after each exclusion, stopping as soon as the target returns +/// . When no target is specified, it defaults to the inverse of the trigger — +/// meaning compaction stops when the trigger condition would no longer fire. +/// +/// /// Strategies can be applied at three lifecycle points: /// /// In-run: During the tool loop, before each LLM call, to keep context within token limits. @@ -43,9 +49,16 @@ public abstract class CompactionStrategy /// /// The that determines whether compaction should proceed. /// - protected CompactionStrategy(CompactionTrigger trigger) + /// + /// An optional target condition that controls when compaction stops. Strategies re-evaluate + /// this predicate after each incremental exclusion and stop when it returns . + /// When , defaults to the inverse of the — compaction + /// stops as soon as the trigger condition would no longer fire. + /// + protected CompactionStrategy(CompactionTrigger trigger, CompactionTrigger? target = null) { this.Trigger = Throw.IfNull(trigger); + this.Target = target ?? (index => !trigger(index)); } /// @@ -53,6 +66,12 @@ protected CompactionStrategy(CompactionTrigger trigger) /// protected CompactionTrigger Trigger { get; } + /// + /// Gets the target predicate that controls when compaction stops. + /// Strategies re-evaluate this after each incremental exclusion and stop when it returns . + /// + protected CompactionTrigger Target { get; } + /// /// Evaluates the and, when it fires, delegates to /// and reports compaction metrics. @@ -98,7 +117,8 @@ public async Task CompactAsync(MessageIndex index, CancellationToken cance /// /// This method is called by only when the /// returns . Implementations do not need to evaluate the trigger or - /// report metrics — the base class handles both. + /// report metrics — the base class handles both. Implementations should use + /// to determine when to stop compacting incrementally. /// /// The message index to compact. The strategy mutates this collection in place. /// The to monitor for cancellation requests. diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionTriggers.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionTriggers.cs index d3eb350ca6..3b32eae376 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionTriggers.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionTriggers.cs @@ -20,6 +20,14 @@ public static class CompactionTriggers public static readonly CompactionTrigger Always = _ => true; + /// + /// Creates a trigger that fires when the included token count is below the specified maximum. + /// + /// The token threshold. Compaction proceeds when included tokens exceed this value. + /// A that evaluates included token count. + public static CompactionTrigger TokensBelow(int maxTokens) => + index => index.IncludedTokenCount < maxTokens; + /// /// Creates a trigger that fires when the included token count exceeds the specified maximum. /// diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/SlidingWindowCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/SlidingWindowCompactionStrategy.cs index accf974963..a811da19f7 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/SlidingWindowCompactionStrategy.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/SlidingWindowCompactionStrategy.cs @@ -1,7 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. using System.Collections.Generic; -using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -42,8 +41,12 @@ public sealed class SlidingWindowCompactionStrategy : CompactionStrategy /// /// The maximum number of user turns to keep. Older turns and their associated responses are removed. /// - public SlidingWindowCompactionStrategy(int maximumTurns = DefaultMaximumTurns) - : base(CompactionTriggers.TurnsExceed(maximumTurns)) + /// + /// An optional target condition that controls when compaction stops. When , + /// defaults to the inverse of the auto-derived trigger — compaction stops as soon as the turn count is within bounds. + /// + public SlidingWindowCompactionStrategy(int maximumTurns = DefaultMaximumTurns, CompactionTrigger? target = null) + : base(CompactionTriggers.TurnsExceed(maximumTurns), target) { this.MaxTurns = maximumTurns; } @@ -71,24 +74,30 @@ protected override Task ApplyCompactionAsync(MessageIndex index, Cancellat return Task.FromResult(false); } - // Determine which turn indices to exclude (oldest) + // Exclude one turn at a time from oldest, re-checking target after each int turnsToRemove = includedTurns.Count - this.MaxTurns; - HashSet excludedTurnIndices = [.. includedTurns.Take(turnsToRemove)]; - bool compacted = false; - for (int i = 0; i < index.Groups.Count; i++) + + for (int t = 0; t < turnsToRemove; t++) { - MessageGroup group = index.Groups[i]; - if (group.IsExcluded || group.Kind == MessageGroupKind.System) + int turnToExclude = includedTurns[t]; + + for (int i = 0; i < index.Groups.Count; i++) { - continue; + MessageGroup group = index.Groups[i]; + if (!group.IsExcluded && group.Kind != MessageGroupKind.System && group.TurnIndex == turnToExclude) + { + group.IsExcluded = true; + group.ExcludeReason = $"Excluded by {nameof(SlidingWindowCompactionStrategy)}"; + } } - if (group.TurnIndex is int ti && excludedTurnIndices.Contains(ti)) + compacted = true; + + // Stop when target condition is met + if (this.Target(index)) { - group.IsExcluded = true; - group.ExcludeReason = $"Excluded by {nameof(SlidingWindowCompactionStrategy)}"; - compacted = true; + break; } } diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/SummarizationCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/SummarizationCompactionStrategy.cs index c995783d23..3ec645e372 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/SummarizationCompactionStrategy.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/SummarizationCompactionStrategy.cs @@ -58,12 +58,17 @@ Omit pleasantries and redundant exchanges. Be factual and brief. /// An optional custom system prompt for the summarization LLM call. When , /// is used. /// + /// + /// An optional target condition that controls when compaction stops. When , + /// defaults to the inverse of the — compaction stops as soon as the trigger would no longer fire. + /// public SummarizationCompactionStrategy( IChatClient chatClient, CompactionTrigger trigger, int preserveRecentGroups = 4, - string? summarizationPrompt = null) - : base(trigger) + string? summarizationPrompt = null, + CompactionTrigger? target = null) + : base(trigger, target) { this.ChatClient = Throw.IfNull(chatClient); this.PreserveRecentGroups = preserveRecentGroups; @@ -100,19 +105,19 @@ protected override async Task ApplyCompactionAsync(MessageIndex index, Can } int protectedFromEnd = Math.Min(this.PreserveRecentGroups, nonSystemIncludedCount); - int groupsToSummarize = nonSystemIncludedCount - protectedFromEnd; + int maxSummarizable = nonSystemIncludedCount - protectedFromEnd; - if (groupsToSummarize <= 0) + if (maxSummarizable <= 0) { return false; } - // Collect the oldest non-system included groups for summarization + // Mark oldest non-system groups for summarization one at a time until the target is met StringBuilder conversationText = new(); int summarized = 0; int insertIndex = -1; - for (int i = 0; i < index.Groups.Count && summarized < groupsToSummarize; i++) + for (int i = 0; i < index.Groups.Count && summarized < maxSummarizable; i++) { MessageGroup group = index.Groups[i]; if (group.IsExcluded || group.Kind == MessageGroupKind.System) @@ -138,6 +143,12 @@ protected override async Task ApplyCompactionAsync(MessageIndex index, Can group.IsExcluded = true; group.ExcludeReason = $"Summarized by {nameof(SummarizationCompactionStrategy)}"; summarized++; + + // Stop marking when target condition is met + if (this.Target(index)) + { + break; + } } if (summarized == 0) @@ -145,7 +156,7 @@ protected override async Task ApplyCompactionAsync(MessageIndex index, Can return false; } - // Generate summary using the chat client + // Generate summary using the chat client (single LLM call for all marked groups) ChatResponse response = await this.ChatClient.GetResponseAsync( [ new ChatMessage(ChatRole.System, this.SummarizationPrompt), diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/ToolResultCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/ToolResultCompactionStrategy.cs index c37d7b7596..8692e47159 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/ToolResultCompactionStrategy.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/ToolResultCompactionStrategy.cs @@ -44,8 +44,12 @@ public sealed class ToolResultCompactionStrategy : CompactionStrategy /// The number of most-recent non-system message groups to protect from collapsing. /// Defaults to , ensuring the current turn's tool interactions remain visible. /// - public ToolResultCompactionStrategy(CompactionTrigger trigger, int preserveRecentGroups = DefaultPreserveRecentGroups) - : base(trigger) + /// + /// An optional target condition that controls when compaction stops. When , + /// defaults to the inverse of the — compaction stops as soon as the trigger would no longer fire. + /// + public ToolResultCompactionStrategy(CompactionTrigger trigger, int preserveRecentGroups = DefaultPreserveRecentGroups, CompactionTrigger? target = null) + : base(trigger, target) { this.PreserveRecentGroups = preserveRecentGroups; } @@ -76,15 +80,30 @@ protected override Task ApplyCompactionAsync(MessageIndex index, Cancellat protectedGroupIndices.Add(nonSystemIncludedIndices[i]); } - // Process from end to start so insertions don't shift earlier indices - bool compacted = false; - for (int i = index.Groups.Count - 1; i >= 0; i--) + // Collect eligible tool groups in order (oldest first) + List eligibleIndices = []; + for (int i = 0; i < index.Groups.Count; i++) { MessageGroup group = index.Groups[i]; - if (group.IsExcluded || group.Kind != MessageGroupKind.ToolCall || protectedGroupIndices.Contains(i)) + if (!group.IsExcluded && group.Kind == MessageGroupKind.ToolCall && !protectedGroupIndices.Contains(i)) { - continue; + eligibleIndices.Add(i); } + } + + if (eligibleIndices.Count == 0) + { + return Task.FromResult(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; + MessageGroup group = index.Groups[idx]; // Extract tool names from FunctionCallContent List toolNames = []; @@ -107,9 +126,16 @@ protected override Task ApplyCompactionAsync(MessageIndex index, Cancellat group.ExcludeReason = $"Collapsed by {nameof(ToolResultCompactionStrategy)}"; string summary = $"[Tool calls: {string.Join(", ", toolNames)}]"; - index.InsertGroup(i + 1, MessageGroupKind.AssistantText, [new ChatMessage(ChatRole.Assistant, summary)], group.TurnIndex); + index.InsertGroup(idx + 1, MessageGroupKind.AssistantText, [new ChatMessage(ChatRole.Assistant, summary)], group.TurnIndex); + offset++; // Each insertion shifts subsequent indices by 1 compacted = true; + + // Stop when target condition is met + if (this.Target(index)) + { + break; + } } return Task.FromResult(compacted); diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/TruncationCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/TruncationCompactionStrategy.cs index 5bd9630b77..57a0eea528 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/TruncationCompactionStrategy.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/TruncationCompactionStrategy.cs @@ -37,8 +37,12 @@ public sealed class TruncationCompactionStrategy : CompactionStrategy /// The minimum number of most-recent non-system message groups to keep. /// Defaults to 1 so that at least the latest exchange is always preserved. /// - public TruncationCompactionStrategy(CompactionTrigger trigger, int preserveRecentGroups = DefaultPreserveRecentGroups) - : base(trigger) + /// + /// An optional target condition that controls when compaction stops. When , + /// defaults to the inverse of the — compaction stops as soon as the trigger would no longer fire. + /// + public TruncationCompactionStrategy(CompactionTrigger trigger, int preserveRecentGroups = DefaultPreserveRecentGroups, CompactionTrigger? target = null) + : base(trigger, target) { this.PreserveRecentGroups = preserveRecentGroups; } @@ -68,7 +72,7 @@ protected override Task ApplyCompactionAsync(MessageIndex index, Cancellat return Task.FromResult(false); } - // Exclude oldest non-system groups first (iterate from the beginning) + // 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++) @@ -83,6 +87,12 @@ protected override Task ApplyCompactionAsync(MessageIndex index, Cancellat group.ExcludeReason = $"Truncated by {nameof(TruncationCompactionStrategy)}"; removed++; compacted = true; + + // Stop when target condition is met + if (this.Target(index)) + { + break; + } } return Task.FromResult(compacted); diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/TruncationCompactionStrategyTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/TruncationCompactionStrategyTests.cs index 6f246a9987..73cb8d6783 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/TruncationCompactionStrategyTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/TruncationCompactionStrategyTests.cs @@ -75,13 +75,13 @@ public async Task CompactAsync_TriggerMet_ExcludesOldestGroupsAsync() // Act bool result = await strategy.CompactAsync(groups); - // Assert + // Assert — incremental: excludes until GroupsExceed(2) is no longer met → 2 groups remain Assert.True(result); - Assert.Equal(1, groups.IncludedGroupCount); - // Oldest 3 excluded, newest 1 kept + Assert.Equal(2, groups.IncludedGroupCount); + // Oldest 2 excluded, newest 2 kept Assert.True(groups.Groups[0].IsExcluded); Assert.True(groups.Groups[1].IsExcluded); - Assert.True(groups.Groups[2].IsExcluded); + Assert.False(groups.Groups[2].IsExcluded); Assert.False(groups.Groups[3].IsExcluded); } From dda15ea4b45c8bf43255489b24c9027538cecc82 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Thu, 5 Mar 2026 02:35:52 -0800 Subject: [PATCH 05/22] Updated --- .../Compaction/CompactingChatClient.cs | 2 +- .../Compaction/CompactionTriggers.cs | 2 +- .../Compaction/MessageIndex.cs | 12 ---- .../Compaction/CompactionTriggersTests.cs | 24 ++++---- .../Compaction/MessageIndexTests.cs | 60 +++++++++---------- .../PipelineCompactionStrategyTests.cs | 14 ++--- .../SlidingWindowCompactionStrategyTests.cs | 14 ++--- .../ToolResultCompactionStrategyTests.cs | 14 ++--- .../TruncationCompactionStrategyTests.cs | 18 +++--- 9 files changed, 74 insertions(+), 86 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactingChatClient.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactingChatClient.cs index 90e97bc611..d2a341292a 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactingChatClient.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactingChatClient.cs @@ -64,7 +64,7 @@ public override async IAsyncEnumerable GetStreamingResponseA [EnumeratorCancellation] CancellationToken cancellationToken = default) { IEnumerable compactedMessages = await this.ApplyCompactionAsync(messages, cancellationToken).ConfigureAwait(false); - await foreach (var update in base.GetStreamingResponseAsync(compactedMessages, options, cancellationToken).ConfigureAwait(false)) + await foreach (ChatResponseUpdate update in base.GetStreamingResponseAsync(compactedMessages, options, cancellationToken).ConfigureAwait(false)) { yield return update; } diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionTriggers.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionTriggers.cs index 3b32eae376..44a426f1e2 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionTriggers.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionTriggers.cs @@ -15,7 +15,7 @@ namespace Microsoft.Agents.AI.Compaction; public static class CompactionTriggers { /// - /// Always triger compaction, regardless of the message index state. + /// Always trigger compaction, regardless of the message index state. /// public static readonly CompactionTrigger Always = _ => true; diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/MessageIndex.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/MessageIndex.cs index c018774d91..87c4597f96 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/MessageIndex.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/MessageIndex.cs @@ -240,8 +240,6 @@ public IEnumerable GetIncludedMessages() => /// 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. /// @@ -262,10 +260,6 @@ public IEnumerable GetIncludedMessages() => /// 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. /// @@ -286,10 +280,6 @@ public IEnumerable GetIncludedMessages() => /// 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). /// @@ -308,8 +298,6 @@ public IEnumerable GetIncludedMessages() => public IEnumerable GetTurnGroups(int turnIndex) => this.Groups.Where(g => g.TurnIndex == turnIndex); - #endregion - /// /// Computes the UTF-8 byte count for a set of messages. /// diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionTriggersTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionTriggersTests.cs index cd85ff3f01..fc41d7b2f1 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionTriggersTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionTriggersTests.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using Microsoft.Agents.AI.Compaction; using Microsoft.Extensions.AI; @@ -11,7 +11,7 @@ namespace Microsoft.Agents.AI.UnitTests.Compaction; public class CompactionTriggersTests { [Fact] - public void TokensExceed_ReturnsTrueWhenAboveThreshold() + public void TokensExceedReturnsTrueWhenAboveThreshold() { // Arrange — use a long message to guarantee tokens > 0 CompactionTrigger trigger = CompactionTriggers.TokensExceed(0); @@ -22,7 +22,7 @@ public void TokensExceed_ReturnsTrueWhenAboveThreshold() } [Fact] - public void TokensExceed_ReturnsFalseWhenBelowThreshold() + public void TokensExceedReturnsFalseWhenBelowThreshold() { CompactionTrigger trigger = CompactionTriggers.TokensExceed(999_999); MessageIndex index = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hi")]); @@ -31,7 +31,7 @@ public void TokensExceed_ReturnsFalseWhenBelowThreshold() } [Fact] - public void MessagesExceed_ReturnsExpectedResult() + public void MessagesExceedReturnsExpectedResult() { CompactionTrigger trigger = CompactionTriggers.MessagesExceed(2); MessageIndex small = MessageIndex.Create( @@ -51,7 +51,7 @@ public void MessagesExceed_ReturnsExpectedResult() } [Fact] - public void TurnsExceed_ReturnsExpectedResult() + public void TurnsExceedReturnsExpectedResult() { CompactionTrigger trigger = CompactionTriggers.TurnsExceed(1); MessageIndex oneTurn = MessageIndex.Create( @@ -71,7 +71,7 @@ public void TurnsExceed_ReturnsExpectedResult() } [Fact] - public void GroupsExceed_ReturnsExpectedResult() + public void GroupsExceedReturnsExpectedResult() { CompactionTrigger trigger = CompactionTriggers.GroupsExceed(2); MessageIndex index = MessageIndex.Create( @@ -85,7 +85,7 @@ public void GroupsExceed_ReturnsExpectedResult() } [Fact] - public void HasToolCalls_ReturnsTrueWhenToolCallGroupExists() + public void HasToolCallsReturnsTrueWhenToolCallGroupExists() { CompactionTrigger trigger = CompactionTriggers.HasToolCalls(); MessageIndex index = MessageIndex.Create( @@ -99,7 +99,7 @@ public void HasToolCalls_ReturnsTrueWhenToolCallGroupExists() } [Fact] - public void HasToolCalls_ReturnsFalseWhenNoToolCallGroup() + public void HasToolCallsReturnsFalseWhenNoToolCallGroup() { CompactionTrigger trigger = CompactionTriggers.HasToolCalls(); MessageIndex index = MessageIndex.Create( @@ -112,7 +112,7 @@ public void HasToolCalls_ReturnsFalseWhenNoToolCallGroup() } [Fact] - public void All_RequiresAllConditions() + public void AllRequiresAllConditions() { CompactionTrigger trigger = CompactionTriggers.All( CompactionTriggers.TokensExceed(0), @@ -125,7 +125,7 @@ public void All_RequiresAllConditions() } [Fact] - public void Any_RequiresAtLeastOneCondition() + public void AnyRequiresAtLeastOneCondition() { CompactionTrigger trigger = CompactionTriggers.Any( CompactionTriggers.TokensExceed(999_999), @@ -138,7 +138,7 @@ public void Any_RequiresAtLeastOneCondition() } [Fact] - public void All_EmptyTriggers_ReturnsTrue() + public void AllEmptyTriggersReturnsTrue() { CompactionTrigger trigger = CompactionTriggers.All(); MessageIndex index = MessageIndex.Create([new ChatMessage(ChatRole.User, "A")]); @@ -146,7 +146,7 @@ public void All_EmptyTriggers_ReturnsTrue() } [Fact] - public void Any_EmptyTriggers_ReturnsFalse() + public void AnyEmptyTriggersReturnsFalse() { CompactionTrigger trigger = CompactionTriggers.Any(); MessageIndex index = MessageIndex.Create([new ChatMessage(ChatRole.User, "A")]); diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/MessageIndexTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/MessageIndexTests.cs index bb84fdd930..3504e1a3d9 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/MessageIndexTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/MessageIndexTests.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System.Collections.Generic; using Microsoft.Agents.AI.Compaction; @@ -12,7 +12,7 @@ namespace Microsoft.Agents.AI.UnitTests.Compaction; public class MessageIndexTests { [Fact] - public void Create_EmptyList_ReturnsEmptyGroups() + public void CreateEmptyListReturnsEmptyGroups() { // Arrange List messages = []; @@ -25,7 +25,7 @@ public void Create_EmptyList_ReturnsEmptyGroups() } [Fact] - public void Create_SystemMessage_CreatesSystemGroup() + public void CreateSystemMessageCreatesSystemGroup() { // Arrange List messages = @@ -43,7 +43,7 @@ public void Create_SystemMessage_CreatesSystemGroup() } [Fact] - public void Create_UserMessage_CreatesUserGroup() + public void CreateUserMessageCreatesUserGroup() { // Arrange List messages = @@ -60,7 +60,7 @@ public void Create_UserMessage_CreatesUserGroup() } [Fact] - public void Create_AssistantTextMessage_CreatesAssistantTextGroup() + public void CreateAssistantTextMessageCreatesAssistantTextGroup() { // Arrange List messages = @@ -77,7 +77,7 @@ public void Create_AssistantTextMessage_CreatesAssistantTextGroup() } [Fact] - public void Create_ToolCallWithResults_CreatesAtomicToolCallGroup() + public void CreateToolCallWithResultsCreatesAtomicToolCallGroup() { // Arrange ChatMessage assistantMessage = new(ChatRole.Assistant, [new FunctionCallContent("call1", "get_weather", new Dictionary { ["city"] = "Seattle" })]); @@ -97,7 +97,7 @@ public void Create_ToolCallWithResults_CreatesAtomicToolCallGroup() } [Fact] - public void Create_MixedConversation_GroupsCorrectly() + public void CreateMixedConversationGroupsCorrectly() { // Arrange ChatMessage systemMsg = new(ChatRole.System, "You are helpful."); @@ -121,7 +121,7 @@ public void Create_MixedConversation_GroupsCorrectly() } [Fact] - public void Create_MultipleToolResults_GroupsAllWithAssistant() + public void CreateMultipleToolResultsGroupsAllWithAssistant() { // Arrange ChatMessage assistantToolCall = new(ChatRole.Assistant, [ @@ -143,7 +143,7 @@ public void Create_MultipleToolResults_GroupsAllWithAssistant() } [Fact] - public void GetIncludedMessages_ExcludesMarkedGroups() + public void GetIncludedMessagesExcludesMarkedGroups() { // Arrange ChatMessage msg1 = new(ChatRole.User, "First"); @@ -163,7 +163,7 @@ public void GetIncludedMessages_ExcludesMarkedGroups() } [Fact] - public void GetAllMessages_IncludesExcludedGroups() + public void GetAllMessagesIncludesExcludedGroups() { // Arrange ChatMessage msg1 = new(ChatRole.User, "First"); @@ -180,7 +180,7 @@ public void GetAllMessages_IncludesExcludedGroups() } [Fact] - public void IncludedGroupCount_ReflectsExclusions() + public void IncludedGroupCountReflectsExclusions() { // Arrange MessageIndex groups = MessageIndex.Create( @@ -198,7 +198,7 @@ public void IncludedGroupCount_ReflectsExclusions() } [Fact] - public void Create_SummaryMessage_CreatesSummaryGroup() + public void CreateSummaryMessageCreatesSummaryGroup() { // Arrange ChatMessage summaryMessage = new(ChatRole.Assistant, "[Summary of earlier conversation]: key facts..."); @@ -216,7 +216,7 @@ public void Create_SummaryMessage_CreatesSummaryGroup() } [Fact] - public void Create_SummaryAmongOtherMessages_GroupsCorrectly() + public void CreateSummaryAmongOtherMessagesGroupsCorrectly() { // Arrange ChatMessage systemMsg = new(ChatRole.System, "You are helpful."); @@ -237,7 +237,7 @@ public void Create_SummaryAmongOtherMessages_GroupsCorrectly() } [Fact] - public void MessageGroup_StoresPassedCounts() + public void MessageGroupStoresPassedCounts() { // Arrange & Act MessageGroup group = new(MessageGroupKind.User, [new ChatMessage(ChatRole.User, "Hello")], byteCount: 5, tokenCount: 2); @@ -249,7 +249,7 @@ public void MessageGroup_StoresPassedCounts() } [Fact] - public void MessageGroup_MessagesAreImmutable() + public void MessageGroupMessagesAreImmutable() { // Arrange IReadOnlyList messages = [new ChatMessage(ChatRole.User, "Hello")]; @@ -261,7 +261,7 @@ public void MessageGroup_MessagesAreImmutable() } [Fact] - public void Create_ComputesByteCount_Utf8() + public void CreateComputesByteCountUtf8() { // Arrange — "Hello" is 5 UTF-8 bytes MessageIndex groups = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]); @@ -271,7 +271,7 @@ public void Create_ComputesByteCount_Utf8() } [Fact] - public void Create_ComputesByteCount_MultiByteChars() + public void CreateComputesByteCountMultiByteChars() { // Arrange — "café" has a multi-byte 'é' (2 bytes in UTF-8) → 5 bytes total MessageIndex groups = MessageIndex.Create([new ChatMessage(ChatRole.User, "café")]); @@ -281,7 +281,7 @@ public void Create_ComputesByteCount_MultiByteChars() } [Fact] - public void Create_ComputesByteCount_MultipleMessagesInGroup() + public void CreateComputesByteCountMultipleMessagesInGroup() { // Arrange — ToolCall group: assistant (tool call, null text) + tool result "OK" (2 bytes) ChatMessage assistantMsg = new(ChatRole.Assistant, [new FunctionCallContent("call1", "fn")]); @@ -295,7 +295,7 @@ public void Create_ComputesByteCount_MultipleMessagesInGroup() } [Fact] - public void Create_DefaultTokenCount_IsHeuristic() + public void CreateDefaultTokenCountIsHeuristic() { // Arrange — "Hello world test data!" = 22 UTF-8 bytes → 22 / 4 = 5 estimated tokens MessageIndex groups = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello world test data!")]); @@ -306,7 +306,7 @@ public void Create_DefaultTokenCount_IsHeuristic() } [Fact] - public void Create_NullText_HasZeroCounts() + public void CreateNullTextHasZeroCounts() { // Arrange — message with no text (e.g., pure function call) ChatMessage msg = new(ChatRole.Assistant, [new FunctionCallContent("call1", "get_weather")]); @@ -320,7 +320,7 @@ public void Create_NullText_HasZeroCounts() } [Fact] - public void TotalAggregates_SumAllGroups() + public void TotalAggregatesSumAllGroups() { // Arrange MessageIndex groups = MessageIndex.Create( @@ -339,7 +339,7 @@ public void TotalAggregates_SumAllGroups() } [Fact] - public void IncludedAggregates_ExcludeMarkedGroups() + public void IncludedAggregatesExcludeMarkedGroups() { // Arrange MessageIndex groups = MessageIndex.Create( @@ -363,7 +363,7 @@ public void IncludedAggregates_ExcludeMarkedGroups() } [Fact] - public void ToolCallGroup_AggregatesAcrossMessages() + public void ToolCallGroupAggregatesAcrossMessages() { // Arrange — tool call group with assistant "Ask" (3 bytes) + tool result "OK" (2 bytes) ChatMessage assistantMsg = new(ChatRole.Assistant, [new FunctionCallContent("call1", "fn")]); @@ -380,7 +380,7 @@ public void ToolCallGroup_AggregatesAcrossMessages() } [Fact] - public void Create_AssignsTurnIndices_SingleTurn() + public void CreateAssignsTurnIndicesSingleTurn() { // Arrange — System (no turn), User + Assistant = turn 1 MessageIndex groups = MessageIndex.Create( @@ -399,7 +399,7 @@ public void Create_AssignsTurnIndices_SingleTurn() } [Fact] - public void Create_AssignsTurnIndices_MultiTurn() + public void CreateAssignsTurnIndicesMultiTurn() { // Arrange — 3 user turns MessageIndex groups = MessageIndex.Create( @@ -423,7 +423,7 @@ public void Create_AssignsTurnIndices_MultiTurn() } [Fact] - public void Create_TurnSpansToolCallGroups() + public void CreateTurnSpansToolCallGroups() { // Arrange — turn 1 includes User, ToolCall, AssistantText ChatMessage assistantToolCall = new(ChatRole.Assistant, [new FunctionCallContent("call1", "get_weather")]); @@ -446,7 +446,7 @@ public void Create_TurnSpansToolCallGroups() } [Fact] - public void GetTurnGroups_ReturnsGroupsForSpecificTurn() + public void GetTurnGroupsReturnsGroupsForSpecificTurn() { // Arrange MessageIndex groups = MessageIndex.Create( @@ -472,7 +472,7 @@ public void GetTurnGroups_ReturnsGroupsForSpecificTurn() } [Fact] - public void IncludedTurnCount_ReflectsExclusions() + public void IncludedTurnCountReflectsExclusions() { // Arrange — 2 turns, exclude all groups in turn 1 MessageIndex groups = MessageIndex.Create( @@ -492,7 +492,7 @@ public void IncludedTurnCount_ReflectsExclusions() } [Fact] - public void TotalTurnCount_ZeroWhenNoUserMessages() + public void TotalTurnCountZeroWhenNoUserMessages() { // Arrange — only system messages MessageIndex groups = MessageIndex.Create( @@ -506,7 +506,7 @@ public void TotalTurnCount_ZeroWhenNoUserMessages() } [Fact] - public void IncludedTurnCount_PartialExclusion_StillCountsTurn() + public void IncludedTurnCountPartialExclusionStillCountsTurn() { // Arrange — turn 1 has 2 groups, only one excluded MessageIndex groups = MessageIndex.Create( diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/PipelineCompactionStrategyTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/PipelineCompactionStrategyTests.cs index ddeb9129d6..d14b019552 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/PipelineCompactionStrategyTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/PipelineCompactionStrategyTests.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System; using System.Collections.Generic; @@ -15,7 +15,7 @@ namespace Microsoft.Agents.AI.UnitTests.Compaction; public class PipelineCompactionStrategyTests { [Fact] - public async Task CompactAsync_ExecutesAllStrategiesInOrderAsync() + public async Task CompactAsyncExecutesAllStrategiesInOrderAsync() { // Arrange List executionOrder = []; @@ -44,7 +44,7 @@ public async Task CompactAsync_ExecutesAllStrategiesInOrderAsync() } [Fact] - public async Task CompactAsync_ReturnsFalse_WhenNoStrategyCompactsAsync() + public async Task CompactAsyncReturnsFalseWhenNoStrategyCompactsAsync() { // Arrange TestCompactionStrategy strategy1 = new(_ => false); @@ -60,7 +60,7 @@ public async Task CompactAsync_ReturnsFalse_WhenNoStrategyCompactsAsync() } [Fact] - public async Task CompactAsync_ReturnsTrue_WhenAnyStrategyCompactsAsync() + public async Task CompactAsyncReturnsTrueWhenAnyStrategyCompactsAsync() { // Arrange TestCompactionStrategy strategy1 = new(_ => false); @@ -77,7 +77,7 @@ public async Task CompactAsync_ReturnsTrue_WhenAnyStrategyCompactsAsync() } [Fact] - public async Task CompactAsync_ContinuesAfterFirstCompaction_WhenEarlyStopDisabledAsync() + public async Task CompactAsyncContinuesAfterFirstCompactionAsync() { // Arrange TestCompactionStrategy strategy1 = new(_ => true); @@ -95,7 +95,7 @@ public async Task CompactAsync_ContinuesAfterFirstCompaction_WhenEarlyStopDisabl } [Fact] - public async Task CompactAsync_ComposesStrategies_EndToEndAsync() + public async Task CompactAsyncComposesStrategiesEndToEndAsync() { // Arrange — pipeline: first exclude oldest 2 non-system groups, then exclude 2 more static void ExcludeOldest2(MessageIndex index) @@ -154,7 +154,7 @@ static void ExcludeOldest2(MessageIndex index) } [Fact] - public async Task CompactAsync_EmptyPipeline_ReturnsFalseAsync() + public async Task CompactAsyncEmptyPipelineReturnsFalseAsync() { // Arrange PipelineCompactionStrategy pipeline = new(new List()); diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/SlidingWindowCompactionStrategyTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/SlidingWindowCompactionStrategyTests.cs index 8a16aa3f21..1c23d9fddd 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/SlidingWindowCompactionStrategyTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/SlidingWindowCompactionStrategyTests.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System.Collections.Generic; using System.Threading.Tasks; @@ -13,7 +13,7 @@ namespace Microsoft.Agents.AI.UnitTests.Compaction; public class SlidingWindowCompactionStrategyTests { [Fact] - public async Task CompactAsync_BelowMaxTurns_ReturnsFalseAsync() + public async Task CompactAsyncBelowMaxTurnsReturnsFalseAsync() { // Arrange SlidingWindowCompactionStrategy strategy = new(maximumTurns: 3); @@ -33,7 +33,7 @@ public async Task CompactAsync_BelowMaxTurns_ReturnsFalseAsync() } [Fact] - public async Task CompactAsync_ExceedsMaxTurns_ExcludesOldestTurnsAsync() + public async Task CompactAsyncExceedsMaxTurnsExcludesOldestTurnsAsync() { // Arrange — keep 2 turns, conversation has 3 SlidingWindowCompactionStrategy strategy = new(maximumTurns: 2); @@ -63,7 +63,7 @@ public async Task CompactAsync_ExceedsMaxTurns_ExcludesOldestTurnsAsync() } [Fact] - public async Task CompactAsync_PreservesSystemMessagesAsync() + public async Task CompactAsyncPreservesSystemMessagesAsync() { // Arrange SlidingWindowCompactionStrategy strategy = new(maximumTurns: 1); @@ -87,7 +87,7 @@ public async Task CompactAsync_PreservesSystemMessagesAsync() } [Fact] - public async Task CompactAsync_PreservesToolCallGroupsInKeptTurnsAsync() + public async Task CompactAsyncPreservesToolCallGroupsInKeptTurnsAsync() { // Arrange SlidingWindowCompactionStrategy strategy = new(maximumTurns: 1); @@ -114,7 +114,7 @@ public async Task CompactAsync_PreservesToolCallGroupsInKeptTurnsAsync() } [Fact] - public async Task CompactAsync_CustomTrigger_OverridesDefaultAsync() + public async Task CompactAsyncCustomTriggerOverridesDefaultAsync() { // Arrange — custom trigger: only compact when tokens exceed threshold SlidingWindowCompactionStrategy strategy = new(maximumTurns: 99); @@ -134,7 +134,7 @@ public async Task CompactAsync_CustomTrigger_OverridesDefaultAsync() } [Fact] - public async Task CompactAsync_IncludedMessages_ContainOnlyKeptTurnsAsync() + public async Task CompactAsyncIncludedMessagesContainOnlyKeptTurnsAsync() { // Arrange SlidingWindowCompactionStrategy strategy = new(maximumTurns: 1); diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/ToolResultCompactionStrategyTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/ToolResultCompactionStrategyTests.cs index 7fc17e8005..a6d13fdb06 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/ToolResultCompactionStrategyTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/ToolResultCompactionStrategyTests.cs @@ -14,7 +14,7 @@ namespace Microsoft.Agents.AI.UnitTests.Compaction; public class ToolResultCompactionStrategyTests { [Fact] - public async Task CompactAsync_TriggerNotMet_ReturnsFalseAsync() + public async Task CompactAsyncTriggerNotMetReturnsFalseAsync() { // Arrange — trigger requires > 1000 tokens ToolResultCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(1000)); @@ -37,7 +37,7 @@ public async Task CompactAsync_TriggerNotMet_ReturnsFalseAsync() } [Fact] - public async Task CompactAsync_CollapsesOldToolGroupsAsync() + public async Task CompactAsyncCollapsesOldToolGroupsAsync() { // Arrange — always trigger ToolResultCompactionStrategy strategy = new( @@ -67,7 +67,7 @@ public async Task CompactAsync_CollapsesOldToolGroupsAsync() } [Fact] - public async Task CompactAsync_PreservesRecentToolGroupsAsync() + public async Task CompactAsyncPreservesRecentToolGroupsAsync() { // Arrange — protect 2 recent non-system groups (the tool group + Q2) ToolResultCompactionStrategy strategy = new( @@ -90,7 +90,7 @@ public async Task CompactAsync_PreservesRecentToolGroupsAsync() } [Fact] - public async Task CompactAsync_PreservesSystemMessagesAsync() + public async Task CompactAsyncPreservesSystemMessagesAsync() { // Arrange ToolResultCompactionStrategy strategy = new( @@ -115,7 +115,7 @@ public async Task CompactAsync_PreservesSystemMessagesAsync() } [Fact] - public async Task CompactAsync_ExtractsMultipleToolNamesAsync() + public async Task CompactAsyncExtractsMultipleToolNamesAsync() { // Arrange — assistant calls two tools ToolResultCompactionStrategy strategy = new( @@ -148,7 +148,7 @@ public async Task CompactAsync_ExtractsMultipleToolNamesAsync() } [Fact] - public async Task CompactAsync_NoToolGroups_ReturnsFalseAsync() + public async Task CompactAsyncNoToolGroupsReturnsFalseAsync() { // Arrange — trigger fires but no tool groups to collapse ToolResultCompactionStrategy strategy = new( @@ -169,7 +169,7 @@ public async Task CompactAsync_NoToolGroups_ReturnsFalseAsync() } [Fact] - public async Task CompactAsync_CompoundTrigger_RequiresTokensAndToolCallsAsync() + public async Task CompactAsyncCompoundTriggerRequiresTokensAndToolCallsAsync() { // Arrange — compound: tokens > 0 AND has tool calls ToolResultCompactionStrategy strategy = new( diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/TruncationCompactionStrategyTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/TruncationCompactionStrategyTests.cs index 73cb8d6783..d884bc1b01 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/TruncationCompactionStrategyTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/TruncationCompactionStrategyTests.cs @@ -15,7 +15,7 @@ public class TruncationCompactionStrategyTests private static readonly CompactionTrigger s_alwaysTrigger = _ => true; [Fact] - public async Task CompactAsync_AlwaysTrigger_CompactsToPreserveRecentAsync() + public async Task CompactAsyncAlwaysTriggerCompactsToPreserveRecentAsync() { // Arrange — always-trigger means always compact TruncationCompactionStrategy strategy = new(s_alwaysTrigger, preserveRecentGroups: 1); @@ -35,7 +35,7 @@ public async Task CompactAsync_AlwaysTrigger_CompactsToPreserveRecentAsync() } [Fact] - public async Task CompactAsync_TriggerNotMet_ReturnsFalseAsync() + public async Task CompactAsyncTriggerNotMetReturnsFalseAsync() { // Arrange — trigger requires > 1000 tokens, conversation is tiny TruncationCompactionStrategy strategy = new( @@ -57,7 +57,7 @@ public async Task CompactAsync_TriggerNotMet_ReturnsFalseAsync() } [Fact] - public async Task CompactAsync_TriggerMet_ExcludesOldestGroupsAsync() + public async Task CompactAsyncTriggerMetExcludesOldestGroupsAsync() { // Arrange — trigger on groups > 2 TruncationCompactionStrategy strategy = new( @@ -86,7 +86,7 @@ public async Task CompactAsync_TriggerMet_ExcludesOldestGroupsAsync() } [Fact] - public async Task CompactAsync_PreservesSystemMessagesAsync() + public async Task CompactAsyncPreservesSystemMessagesAsync() { // Arrange TruncationCompactionStrategy strategy = new(s_alwaysTrigger, preserveRecentGroups: 1); @@ -114,7 +114,7 @@ public async Task CompactAsync_PreservesSystemMessagesAsync() } [Fact] - public async Task CompactAsync_PreservesToolCallGroupAtomicityAsync() + public async Task CompactAsyncPreservesToolCallGroupAtomicityAsync() { // Arrange TruncationCompactionStrategy strategy = new(s_alwaysTrigger, preserveRecentGroups: 1); @@ -138,7 +138,7 @@ public async Task CompactAsync_PreservesToolCallGroupAtomicityAsync() } [Fact] - public async Task CompactAsync_SetsExcludeReasonAsync() + public async Task CompactAsyncSetsExcludeReasonAsync() { // Arrange TruncationCompactionStrategy strategy = new(s_alwaysTrigger, preserveRecentGroups: 1); @@ -157,7 +157,7 @@ public async Task CompactAsync_SetsExcludeReasonAsync() } [Fact] - public async Task CompactAsync_SkipsAlreadyExcludedGroupsAsync() + public async Task CompactAsyncSkipsAlreadyExcludedGroupsAsync() { // Arrange TruncationCompactionStrategy strategy = new(s_alwaysTrigger, preserveRecentGroups: 1); @@ -180,7 +180,7 @@ public async Task CompactAsync_SkipsAlreadyExcludedGroupsAsync() } [Fact] - public async Task CompactAsync_PreserveRecentGroups_KeepsMultipleAsync() + public async Task CompactAsyncPreserveRecentGroupsKeepsMultipleAsync() { // Arrange — keep 2 most recent TruncationCompactionStrategy strategy = new(s_alwaysTrigger, preserveRecentGroups: 2); @@ -204,7 +204,7 @@ public async Task CompactAsync_PreserveRecentGroups_KeepsMultipleAsync() } [Fact] - public async Task CompactAsync_NothingToRemove_ReturnsFalseAsync() + public async Task CompactAsyncNothingToRemoveReturnsFalseAsync() { // Arrange — preserve 5 but only 2 groups TruncationCompactionStrategy strategy = new(s_alwaysTrigger, preserveRecentGroups: 5); From 7608005dd7d26c2eb928615ad22daf8ffe2d9909 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Thu, 5 Mar 2026 02:36:22 -0800 Subject: [PATCH 06/22] Encoding --- .../Microsoft.Agents.AI/ChatClient/ChatClientAgentOptions.cs | 2 +- dotnet/src/Microsoft.Agents.AI/Compaction/CompactionTrigger.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentOptions.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentOptions.cs index 28b5643a30..20dd3647d7 100644 --- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentOptions.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System.Collections.Generic; using Microsoft.Agents.AI.Compaction; diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionTrigger.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionTrigger.cs index 2ca28e2005..fb58bc1a2c 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionTrigger.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionTrigger.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. namespace Microsoft.Agents.AI.Compaction; From 1428286bd18421628a21a7710d799113912ba877 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Thu, 5 Mar 2026 02:41:26 -0800 Subject: [PATCH 07/22] Formatting --- dotnet/src/Microsoft.Agents.AI/Compaction/MessageGroupKind.cs | 2 ++ dotnet/src/Microsoft.Agents.AI/Compaction/MessageIndex.cs | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/MessageGroupKind.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/MessageGroupKind.cs index f3ee4c072f..68d7d1fc09 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/MessageGroupKind.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/MessageGroupKind.cs @@ -37,6 +37,7 @@ public enum MessageGroupKind /// ToolCall, +#pragma warning disable IDE0001 // Simplify Names /// /// A summary message group produced by a compaction strategy (e.g., SummarizationCompactionStrategy). /// @@ -45,5 +46,6 @@ public enum MessageGroupKind /// They are identified by the metadata entry /// on the underlying . /// +#pragma warning restore IDE0001 // Simplify Names Summary, } diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/MessageIndex.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/MessageIndex.cs index 87c4597f96..f8467eb3ce 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/MessageIndex.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/MessageIndex.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using System.Text; using Microsoft.Extensions.AI; @@ -97,7 +98,8 @@ public MessageIndex(IList groups, Tokenizer? tokenizer = null) /// public static MessageIndex Create(IList messages, Tokenizer? tokenizer = null) { - MessageIndex instance = new(new List(), tokenizer); + Debug.WriteLine("COMPACTION: Creating index x{messages.Count} messages"); + MessageIndex instance = new([], tokenizer); instance.AppendFromMessages(messages, 0); return instance; } From f70423bd99911cc0bc80a57af098c5272ddcae36 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Thu, 5 Mar 2026 02:45:27 -0800 Subject: [PATCH 08/22] Cleanup --- ...emoryChatHistoryProviderCompactionTests.cs | 269 ------------------ 1 file changed, 269 deletions(-) delete mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/InMemoryChatHistoryProviderCompactionTests.cs diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/InMemoryChatHistoryProviderCompactionTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/InMemoryChatHistoryProviderCompactionTests.cs deleted file mode 100644 index 95cfb88aaa..0000000000 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/InMemoryChatHistoryProviderCompactionTests.cs +++ /dev/null @@ -1,269 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -// %%% SAVE - RE-ANALYZE -//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.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); -// } -//} From defb9dd51c497c2026ab8c34a36be3582859cf35 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Thu, 5 Mar 2026 02:51:05 -0800 Subject: [PATCH 09/22] Formatting --- .../Compaction/ToolResultCompactionStrategyTests.cs | 3 +-- .../Compaction/TruncationCompactionStrategyTests.cs | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/ToolResultCompactionStrategyTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/ToolResultCompactionStrategyTests.cs index a6d13fdb06..ad4fa89bb5 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/ToolResultCompactionStrategyTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/ToolResultCompactionStrategyTests.cs @@ -1,7 +1,6 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System.Collections.Generic; -using System.Linq; using System.Threading.Tasks; using Microsoft.Agents.AI.Compaction; using Microsoft.Extensions.AI; diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/TruncationCompactionStrategyTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/TruncationCompactionStrategyTests.cs index d884bc1b01..5d4ac003f6 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/TruncationCompactionStrategyTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/TruncationCompactionStrategyTests.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System.Linq; using System.Threading.Tasks; From 6ce0447ff6d23a48729451cd8dc69ecd51f271f4 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Thu, 5 Mar 2026 03:00:32 -0800 Subject: [PATCH 10/22] Tests --- .../Compaction/CompactionStrategyTests.cs | 166 ++++++++++ .../Compaction/CompactionTriggersTests.cs | 25 ++ .../Compaction/MessageIndexTests.cs | 154 +++++++++ .../SlidingWindowCompactionStrategyTests.cs | 34 ++ .../SummarizationCompactionStrategyTests.cs | 302 ++++++++++++++++++ .../ToolResultCompactionStrategyTests.cs | 42 +++ .../TruncationCompactionStrategyTests.cs | 56 ++++ 7 files changed, 779 insertions(+) create mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionStrategyTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/SummarizationCompactionStrategyTests.cs diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionStrategyTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionStrategyTests.cs new file mode 100644 index 0000000000..a89ac0702e --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionStrategyTests.cs @@ -0,0 +1,166 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Compaction; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.UnitTests.Compaction; + +/// +/// Contains tests for the abstract base class. +/// +public class CompactionStrategyTests +{ + [Fact] + public void ConstructorNullTriggerThrows() + { + // Act & Assert + Assert.Throws(() => new TestStrategy(null!)); + } + + [Fact] + public async Task CompactAsyncTriggerNotMetReturnsFalseAsync() + { + // Arrange — trigger never fires + TestStrategy strategy = new(_ => false); + MessageIndex index = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]); + + // Act + bool result = await strategy.CompactAsync(index); + + // Assert + Assert.False(result); + Assert.Equal(0, strategy.ApplyCallCount); + } + + [Fact] + public async Task CompactAsyncTriggerMetCallsApplyAsync() + { + // Arrange — trigger always fires + TestStrategy strategy = new(_ => true, applyFunc: _ => true); + MessageIndex index = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]); + + // Act + bool result = await strategy.CompactAsync(index); + + // Assert + Assert.True(result); + Assert.Equal(1, strategy.ApplyCallCount); + } + + [Fact] + public async Task CompactAsyncReturnsFalseWhenApplyReturnsFalseAsync() + { + // Arrange — trigger fires but Apply does nothing + TestStrategy strategy = new(_ => true, applyFunc: _ => false); + MessageIndex index = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]); + + // Act + bool result = await strategy.CompactAsync(index); + + // Assert + Assert.False(result); + Assert.Equal(1, strategy.ApplyCallCount); + } + + [Fact] + public async Task CompactAsyncDefaultTargetIsInverseOfTriggerAsync() + { + // Arrange — trigger fires when groups > 2 + // Default target should be: stop when groups <= 2 (i.e., !trigger) + CompactionTrigger trigger = CompactionTriggers.GroupsExceed(2); + TestStrategy strategy = new(trigger, applyFunc: index => + { + // Exclude oldest non-system group one at a time + foreach (MessageGroup group in index.Groups) + { + if (!group.IsExcluded && group.Kind != MessageGroupKind.System) + { + group.IsExcluded = true; + // Target (default = !trigger) returns true when groups <= 2 + // So the strategy would check Target after this exclusion + break; + } + } + + return true; + }); + + MessageIndex index = MessageIndex.Create( + [ + new ChatMessage(ChatRole.User, "Q1"), + new ChatMessage(ChatRole.Assistant, "A1"), + new ChatMessage(ChatRole.User, "Q2"), + new ChatMessage(ChatRole.Assistant, "A2"), + ]); + + // Act + bool result = await strategy.CompactAsync(index); + + // Assert — trigger fires (4 > 2), Apply is called + Assert.True(result); + Assert.Equal(1, strategy.ApplyCallCount); + } + + [Fact] + public async Task CompactAsyncCustomTargetIsPassedToStrategyAsync() + { + // Arrange — custom target that always signals stop + bool targetCalled = false; + CompactionTrigger customTarget = _ => + { + targetCalled = true; + return true; + }; + + TestStrategy strategy = new(_ => true, customTarget, _ => + { + // Access the target from within the strategy + return true; + }); + + MessageIndex index = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]); + + // Act + await strategy.CompactAsync(index); + + // Assert — the custom target is accessible (verified by TestStrategy checking it) + Assert.Equal(1, strategy.ApplyCallCount); + // The target is accessible to derived classes via the protected property + Assert.True(strategy.InvokeTarget(index)); + Assert.True(targetCalled); + } + + /// + /// A concrete test implementation of for testing the base class. + /// + private sealed class TestStrategy : CompactionStrategy + { + private readonly Func? _applyFunc; + + public TestStrategy( + CompactionTrigger trigger, + CompactionTrigger? target = null, + Func? applyFunc = null) + : base(trigger, target) + { + this._applyFunc = applyFunc; + } + + public int ApplyCallCount { get; private set; } + + /// + /// Exposes the protected Target property for test verification. + /// + public bool InvokeTarget(MessageIndex index) => this.Target(index); + + protected override Task ApplyCompactionAsync(MessageIndex index, CancellationToken cancellationToken) + { + this.ApplyCallCount++; + bool result = this._applyFunc?.Invoke(index) ?? false; + return Task.FromResult(result); + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionTriggersTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionTriggersTests.cs index fc41d7b2f1..fe4ae320a9 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionTriggersTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionTriggersTests.cs @@ -152,4 +152,29 @@ public void AnyEmptyTriggersReturnsFalse() MessageIndex index = MessageIndex.Create([new ChatMessage(ChatRole.User, "A")]); Assert.False(trigger(index)); } + + [Fact] + public void TokensBelowReturnsTrueWhenBelowThreshold() + { + CompactionTrigger trigger = CompactionTriggers.TokensBelow(999_999); + MessageIndex index = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hi")]); + + Assert.True(trigger(index)); + } + + [Fact] + public void TokensBelowReturnsFalseWhenAboveThreshold() + { + CompactionTrigger trigger = CompactionTriggers.TokensBelow(0); + MessageIndex index = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello world")]); + + Assert.False(trigger(index)); + } + + [Fact] + public void AlwaysReturnsTrue() + { + MessageIndex index = MessageIndex.Create([new ChatMessage(ChatRole.User, "A")]); + Assert.True(CompactionTriggers.Always(index)); + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/MessageIndexTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/MessageIndexTests.cs index 3504e1a3d9..9f0eabf9d8 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/MessageIndexTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/MessageIndexTests.cs @@ -521,4 +521,158 @@ public void IncludedTurnCountPartialExclusionStillCountsTurn() Assert.Equal(1, groups.TotalTurnCount); Assert.Equal(1, groups.IncludedTurnCount); } + + [Fact] + public void UpdateAppendsNewMessagesIncrementally() + { + // Arrange — create with 2 messages + List messages = + [ + new ChatMessage(ChatRole.User, "Q1"), + new ChatMessage(ChatRole.Assistant, "A1"), + ]; + MessageIndex index = MessageIndex.Create(messages); + Assert.Equal(2, index.Groups.Count); + Assert.Equal(2, index.ProcessedMessageCount); + + // Act — add 2 more messages and update + messages.Add(new ChatMessage(ChatRole.User, "Q2")); + messages.Add(new ChatMessage(ChatRole.Assistant, "A2")); + index.Update(messages); + + // Assert — should have 4 groups total, processed count updated + Assert.Equal(4, index.Groups.Count); + Assert.Equal(4, index.ProcessedMessageCount); + Assert.Equal(MessageGroupKind.User, index.Groups[2].Kind); + Assert.Equal(MessageGroupKind.AssistantText, index.Groups[3].Kind); + } + + [Fact] + public void UpdateNoOpWhenNoNewMessages() + { + // Arrange + List messages = + [ + new ChatMessage(ChatRole.User, "Q1"), + ]; + MessageIndex index = MessageIndex.Create(messages); + int originalCount = index.Groups.Count; + + // Act — update with same count + index.Update(messages); + + // Assert — nothing changed + Assert.Equal(originalCount, index.Groups.Count); + } + + [Fact] + public void UpdateRebuildsWhenMessagesShrink() + { + // Arrange — create with 3 messages + List messages = + [ + new ChatMessage(ChatRole.User, "Q1"), + new ChatMessage(ChatRole.Assistant, "A1"), + new ChatMessage(ChatRole.User, "Q2"), + ]; + MessageIndex index = MessageIndex.Create(messages); + Assert.Equal(3, index.Groups.Count); + + // Exclude a group to verify rebuild clears state + index.Groups[0].IsExcluded = true; + + // Act — update with fewer messages (simulates storage compaction) + List shortened = + [ + new ChatMessage(ChatRole.User, "Q2"), + ]; + index.Update(shortened); + + // Assert — rebuilt from scratch + Assert.Single(index.Groups); + Assert.False(index.Groups[0].IsExcluded); + Assert.Equal(1, index.ProcessedMessageCount); + } + + [Fact] + public void UpdatePreservesExistingGroupExclusionState() + { + // Arrange + List messages = + [ + new ChatMessage(ChatRole.User, "Q1"), + new ChatMessage(ChatRole.Assistant, "A1"), + ]; + MessageIndex index = MessageIndex.Create(messages); + index.Groups[0].IsExcluded = true; + index.Groups[0].ExcludeReason = "Test exclusion"; + + // Act — append new messages + messages.Add(new ChatMessage(ChatRole.User, "Q2")); + index.Update(messages); + + // Assert — original exclusion state preserved + Assert.True(index.Groups[0].IsExcluded); + Assert.Equal("Test exclusion", index.Groups[0].ExcludeReason); + Assert.Equal(3, index.Groups.Count); + } + + [Fact] + public void InsertGroupInsertsAtSpecifiedIndex() + { + // Arrange + MessageIndex index = MessageIndex.Create( + [ + new ChatMessage(ChatRole.User, "Q1"), + new ChatMessage(ChatRole.User, "Q2"), + ]); + + // Act — insert between Q1 and Q2 + ChatMessage summaryMsg = new(ChatRole.Assistant, "[Summary]"); + MessageGroup inserted = index.InsertGroup(1, MessageGroupKind.Summary, [summaryMsg], turnIndex: 1); + + // Assert + Assert.Equal(3, index.Groups.Count); + Assert.Same(inserted, index.Groups[1]); + Assert.Equal(MessageGroupKind.Summary, index.Groups[1].Kind); + Assert.Equal("[Summary]", index.Groups[1].Messages[0].Text); + Assert.Equal(1, inserted.TurnIndex); + } + + [Fact] + public void AddGroupAppendsToEnd() + { + // Arrange + MessageIndex index = MessageIndex.Create( + [ + new ChatMessage(ChatRole.User, "Q1"), + ]); + + // Act + ChatMessage msg = new(ChatRole.Assistant, "Appended"); + MessageGroup added = index.AddGroup(MessageGroupKind.AssistantText, [msg], turnIndex: 1); + + // Assert + Assert.Equal(2, index.Groups.Count); + Assert.Same(added, index.Groups[1]); + Assert.Equal("Appended", index.Groups[1].Messages[0].Text); + } + + [Fact] + public void InsertGroupComputesByteAndTokenCounts() + { + // Arrange + MessageIndex index = MessageIndex.Create( + [ + new ChatMessage(ChatRole.User, "Q1"), + ]); + + // Act — insert a group with known text + ChatMessage msg = new(ChatRole.Assistant, "Hello"); // 5 bytes, ~1 token (5/4) + MessageGroup inserted = index.InsertGroup(0, MessageGroupKind.AssistantText, [msg]); + + // Assert + Assert.Equal(5, inserted.ByteCount); + Assert.Equal(1, inserted.TokenCount); // 5 / 4 = 1 (integer division) + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/SlidingWindowCompactionStrategyTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/SlidingWindowCompactionStrategyTests.cs index 1c23d9fddd..74d057e9f0 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/SlidingWindowCompactionStrategyTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/SlidingWindowCompactionStrategyTests.cs @@ -157,4 +157,38 @@ public async Task CompactAsyncIncludedMessagesContainOnlyKeptTurnsAsync() Assert.Equal("Q2", included[1].Text); Assert.Equal("A2", included[2].Text); } + + [Fact] + public async Task CompactAsyncCustomTargetStopsExcludingEarlyAsync() + { + // Arrange — 4 turns, maxTurns=1 means 3 should be excluded + // But custom target stops after removing 1 turn + int removeCount = 0; + CompactionTrigger targetAfterOne = _ => ++removeCount >= 1; + + SlidingWindowCompactionStrategy strategy = new( + maximumTurns: 1, + target: targetAfterOne); + + MessageIndex index = MessageIndex.Create( + [ + 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"), + new ChatMessage(ChatRole.Assistant, "A3"), + new ChatMessage(ChatRole.User, "Q4"), + ]); + + // Act + bool result = await strategy.CompactAsync(index); + + // Assert — only turn 1 excluded (target stopped after 1 removal) + Assert.True(result); + Assert.True(index.Groups[0].IsExcluded); // Q1 (turn 1) + Assert.True(index.Groups[1].IsExcluded); // A1 (turn 1) + Assert.False(index.Groups[2].IsExcluded); // Q2 (turn 2) — kept + Assert.False(index.Groups[3].IsExcluded); // A2 (turn 2) + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/SummarizationCompactionStrategyTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/SummarizationCompactionStrategyTests.cs new file mode 100644 index 0000000000..a71fc4697f --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/SummarizationCompactionStrategyTests.cs @@ -0,0 +1,302 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Compaction; +using Microsoft.Extensions.AI; +using Moq; + +namespace Microsoft.Agents.AI.UnitTests.Compaction; + +/// +/// Contains tests for the class. +/// +public class SummarizationCompactionStrategyTests +{ + private static readonly CompactionTrigger AlwaysTrigger = _ => true; + + /// + /// Creates a mock that returns the specified summary text. + /// + private static IChatClient CreateMockChatClient(string summaryText = "Summary of conversation.") + { + Mock mock = new(); + mock.Setup(c => c.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new ChatResponse([new ChatMessage(ChatRole.Assistant, summaryText)])); + return mock.Object; + } + + [Fact] + public async Task CompactAsyncTriggerNotMetReturnsFalseAsync() + { + // Arrange — trigger requires > 100000 tokens + SummarizationCompactionStrategy strategy = new( + CreateMockChatClient(), + CompactionTriggers.TokensExceed(100000), + preserveRecentGroups: 1); + + MessageIndex index = MessageIndex.Create( + [ + new ChatMessage(ChatRole.User, "Hello"), + new ChatMessage(ChatRole.Assistant, "Hi!"), + ]); + + // Act + bool result = await strategy.CompactAsync(index); + + // Assert + Assert.False(result); + Assert.Equal(2, index.IncludedGroupCount); + } + + [Fact] + public async Task CompactAsyncSummarizesOldGroupsAsync() + { + // Arrange — always trigger, preserve 1 recent group + SummarizationCompactionStrategy strategy = new( + CreateMockChatClient("Key facts from earlier."), + AlwaysTrigger, + preserveRecentGroups: 1); + + MessageIndex index = MessageIndex.Create( + [ + new ChatMessage(ChatRole.User, "First question"), + new ChatMessage(ChatRole.Assistant, "First answer"), + new ChatMessage(ChatRole.User, "Second question"), + ]); + + // Act + bool result = await strategy.CompactAsync(index); + + // Assert + Assert.True(result); + + List included = [.. index.GetIncludedMessages()]; + + // Should have: summary + preserved recent group (Second question) + Assert.Equal(2, included.Count); + Assert.Contains("[Summary]", included[0].Text); + Assert.Contains("Key facts from earlier.", included[0].Text); + Assert.Equal("Second question", included[1].Text); + } + + [Fact] + public async Task CompactAsyncPreservesSystemMessagesAsync() + { + // Arrange + SummarizationCompactionStrategy strategy = new( + CreateMockChatClient(), + AlwaysTrigger, + preserveRecentGroups: 1); + + MessageIndex index = MessageIndex.Create( + [ + new ChatMessage(ChatRole.System, "You are helpful."), + new ChatMessage(ChatRole.User, "Old question"), + new ChatMessage(ChatRole.Assistant, "Old answer"), + new ChatMessage(ChatRole.User, "Recent question"), + ]); + + // Act + await strategy.CompactAsync(index); + + // Assert + List included = [.. index.GetIncludedMessages()]; + + Assert.Equal("You are helpful.", included[0].Text); + Assert.Equal(ChatRole.System, included[0].Role); + } + + [Fact] + public async Task CompactAsyncInsertsSummaryGroupAtCorrectPositionAsync() + { + // Arrange + SummarizationCompactionStrategy strategy = new( + CreateMockChatClient("Summary text."), + AlwaysTrigger, + preserveRecentGroups: 1); + + MessageIndex index = MessageIndex.Create( + [ + new ChatMessage(ChatRole.System, "System prompt."), + new ChatMessage(ChatRole.User, "Q1"), + new ChatMessage(ChatRole.Assistant, "A1"), + new ChatMessage(ChatRole.User, "Q2"), + ]); + + // Act + await strategy.CompactAsync(index); + + // Assert — summary should be inserted after system, before preserved group + MessageGroup summaryGroup = index.Groups.First(g => g.Kind == MessageGroupKind.Summary); + Assert.NotNull(summaryGroup); + Assert.Contains("[Summary]", summaryGroup.Messages[0].Text); + Assert.True(summaryGroup.Messages[0].AdditionalProperties!.ContainsKey(MessageGroup.SummaryPropertyKey)); + } + + [Fact] + public async Task CompactAsyncHandlesEmptyLlmResponseAsync() + { + // Arrange — LLM returns whitespace + SummarizationCompactionStrategy strategy = new( + CreateMockChatClient(" "), + AlwaysTrigger, + preserveRecentGroups: 1); + + MessageIndex index = MessageIndex.Create( + [ + new ChatMessage(ChatRole.User, "Q1"), + new ChatMessage(ChatRole.User, "Q2"), + ]); + + // Act + await strategy.CompactAsync(index); + + // Assert — should use fallback text + List included = [.. index.GetIncludedMessages()]; + Assert.Contains("[Summary unavailable]", included[0].Text); + } + + [Fact] + public async Task CompactAsyncNothingToSummarizeReturnsFalseAsync() + { + // Arrange — preserve 5 but only 2 non-system groups + SummarizationCompactionStrategy strategy = new( + CreateMockChatClient(), + AlwaysTrigger, + preserveRecentGroups: 5); + + MessageIndex index = MessageIndex.Create( + [ + new ChatMessage(ChatRole.User, "Hello"), + new ChatMessage(ChatRole.Assistant, "Hi!"), + ]); + + // Act + bool result = await strategy.CompactAsync(index); + + // Assert + Assert.False(result); + } + + [Fact] + public async Task CompactAsyncUsesCustomPromptAsync() + { + // Arrange — capture the messages sent to the chat client + List? capturedMessages = null; + Mock mockClient = new(); + mockClient.Setup(c => c.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Callback, ChatOptions?, CancellationToken>((msgs, _, _) => + capturedMessages = [.. msgs]) + .ReturnsAsync(new ChatResponse([new ChatMessage(ChatRole.Assistant, "Custom summary.")])); + + const string customPrompt = "Summarize in bullet points only."; + SummarizationCompactionStrategy strategy = new( + mockClient.Object, + AlwaysTrigger, + preserveRecentGroups: 1, + summarizationPrompt: customPrompt); + + MessageIndex index = MessageIndex.Create( + [ + new ChatMessage(ChatRole.User, "Q1"), + new ChatMessage(ChatRole.User, "Q2"), + ]); + + // Act + await strategy.CompactAsync(index); + + // Assert — the custom prompt should be the first message sent to the LLM + Assert.NotNull(capturedMessages); + Assert.Equal(customPrompt, capturedMessages![0].Text); + } + + [Fact] + public async Task CompactAsyncSetsExcludeReasonAsync() + { + // Arrange + SummarizationCompactionStrategy strategy = new( + CreateMockChatClient(), + AlwaysTrigger, + preserveRecentGroups: 1); + + MessageIndex index = MessageIndex.Create( + [ + new ChatMessage(ChatRole.User, "Old"), + new ChatMessage(ChatRole.User, "New"), + ]); + + // Act + await strategy.CompactAsync(index); + + // Assert + MessageGroup excluded = index.Groups.First(g => g.IsExcluded); + Assert.NotNull(excluded.ExcludeReason); + Assert.Contains("SummarizationCompactionStrategy", excluded.ExcludeReason); + } + + [Fact] + public async Task CompactAsyncTargetStopsMarkingEarlyAsync() + { + // Arrange — 4 non-system groups, preserve 1, target met after 1 exclusion + int exclusionCount = 0; + CompactionTrigger targetAfterOne = _ => ++exclusionCount >= 1; + + SummarizationCompactionStrategy strategy = new( + CreateMockChatClient("Partial summary."), + AlwaysTrigger, + preserveRecentGroups: 1, + target: targetAfterOne); + + MessageIndex index = MessageIndex.Create( + [ + new ChatMessage(ChatRole.User, "Q1"), + new ChatMessage(ChatRole.Assistant, "A1"), + new ChatMessage(ChatRole.User, "Q2"), + new ChatMessage(ChatRole.User, "Q3"), + ]); + + // Act + await strategy.CompactAsync(index); + + // Assert — only 1 group should have been summarized (target met after first exclusion) + int excludedCount = index.Groups.Count(g => g.IsExcluded); + Assert.Equal(1, excludedCount); + } + + [Fact] + public async Task CompactAsyncPreservesMultipleRecentGroupsAsync() + { + // Arrange — preserve 2 + SummarizationCompactionStrategy strategy = new( + CreateMockChatClient("Summary."), + AlwaysTrigger, + preserveRecentGroups: 2); + + MessageIndex index = MessageIndex.Create( + [ + new ChatMessage(ChatRole.User, "Q1"), + new ChatMessage(ChatRole.Assistant, "A1"), + new ChatMessage(ChatRole.User, "Q2"), + new ChatMessage(ChatRole.Assistant, "A2"), + ]); + + // Act + await strategy.CompactAsync(index); + + // Assert — 2 oldest excluded, 2 newest preserved + 1 summary inserted + List included = [.. index.GetIncludedMessages()]; + Assert.Equal(3, included.Count); // summary + Q2 + A2 + Assert.Contains("[Summary]", included[0].Text); + Assert.Equal("Q2", included[1].Text); + Assert.Equal("A2", included[2].Text); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/ToolResultCompactionStrategyTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/ToolResultCompactionStrategyTests.cs index ad4fa89bb5..39c03f2631 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/ToolResultCompactionStrategyTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/ToolResultCompactionStrategyTests.cs @@ -191,4 +191,46 @@ public async Task CompactAsyncCompoundTriggerRequiresTokensAndToolCallsAsync() // Assert Assert.True(result); } + + [Fact] + public async Task CompactAsyncTargetStopsCollapsingEarlyAsync() + { + // Arrange — 2 tool groups, target met after first collapse + int collapseCount = 0; + CompactionTrigger targetAfterOne = _ => ++collapseCount >= 1; + + ToolResultCompactionStrategy strategy = new( + trigger: _ => true, + preserveRecentGroups: 1, + target: targetAfterOne); + + MessageIndex index = MessageIndex.Create( + [ + new ChatMessage(ChatRole.User, "Q1"), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "fn1")]), + new ChatMessage(ChatRole.Tool, "result1"), + new ChatMessage(ChatRole.User, "Q2"), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c2", "fn2")]), + new ChatMessage(ChatRole.Tool, "result2"), + new ChatMessage(ChatRole.User, "Q3"), + ]); + + // Act + bool result = await strategy.CompactAsync(index); + + // Assert — only first tool group collapsed, second left intact + Assert.True(result); + + // Count collapsed tool groups (excluded with ToolCall kind) + int collapsedToolGroups = 0; + foreach (MessageGroup group in index.Groups) + { + if (group.IsExcluded && group.Kind == MessageGroupKind.ToolCall) + { + collapsedToolGroups++; + } + } + + Assert.Equal(1, collapsedToolGroups); + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/TruncationCompactionStrategyTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/TruncationCompactionStrategyTests.cs index 5d4ac003f6..1799758ddd 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/TruncationCompactionStrategyTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/TruncationCompactionStrategyTests.cs @@ -220,4 +220,60 @@ public async Task CompactAsyncNothingToRemoveReturnsFalseAsync() // Assert Assert.False(result); } + + [Fact] + public async Task CompactAsyncCustomTargetStopsEarlyAsync() + { + // Arrange — always trigger, custom target stops after 1 exclusion + int targetChecks = 0; + CompactionTrigger targetAfterOne = _ => ++targetChecks >= 1; + + TruncationCompactionStrategy strategy = new( + s_alwaysTrigger, + preserveRecentGroups: 1, + target: targetAfterOne); + + MessageIndex groups = MessageIndex.Create( + [ + new ChatMessage(ChatRole.User, "Q1"), + new ChatMessage(ChatRole.Assistant, "A1"), + new ChatMessage(ChatRole.User, "Q2"), + new ChatMessage(ChatRole.User, "Q3"), + ]); + + // Act + bool result = await strategy.CompactAsync(groups); + + // Assert — only 1 group excluded (target met after first) + Assert.True(result); + Assert.True(groups.Groups[0].IsExcluded); + Assert.False(groups.Groups[1].IsExcluded); + Assert.False(groups.Groups[2].IsExcluded); + Assert.False(groups.Groups[3].IsExcluded); + } + + [Fact] + public async Task CompactAsyncIncrementalStopsAtTargetAsync() + { + // Arrange — trigger on groups > 2, target is default (inverse of trigger: groups <= 2) + TruncationCompactionStrategy strategy = new( + CompactionTriggers.GroupsExceed(2), + preserveRecentGroups: 1); + + MessageIndex groups = MessageIndex.Create( + [ + 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 — 5 groups, trigger fires (5 > 2), compacts until groups <= 2 + bool result = await strategy.CompactAsync(groups); + + // Assert — should stop at 2 included groups (not go all the way to 1) + Assert.True(result); + Assert.Equal(2, groups.IncludedGroupCount); + } } From 7e2c5ad4e6acf3792e701c2ae5c78a5b50b1cfe9 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Thu, 5 Mar 2026 03:10:40 -0800 Subject: [PATCH 11/22] Tuning --- .../Program.cs | 2 +- .../SlidingWindowCompactionStrategy.cs | 82 ++++++++++++------- .../SummarizationCompactionStrategy.cs | 24 ++++-- .../ToolResultCompactionStrategy.cs | 27 +++--- .../TruncationCompactionStrategy.cs | 28 ++++--- .../SlidingWindowCompactionStrategyTests.cs | 64 +++++++++++---- .../SummarizationCompactionStrategyTests.cs | 20 ++--- .../ToolResultCompactionStrategyTests.cs | 16 ++-- .../TruncationCompactionStrategyTests.cs | 26 +++--- 9 files changed, 181 insertions(+), 108 deletions(-) diff --git a/dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/Program.cs b/dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/Program.cs index 0a57de892e..0118ac1d60 100644 --- a/dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/Program.cs +++ b/dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/Program.cs @@ -47,7 +47,7 @@ static string LookupPrice([Description("The product name to look up.")] string p new SummarizationCompactionStrategy(summarizerChatClient, CompactionTriggers.TokensExceed(0x500)), // 3. Aggressive: keep only the last N user turns and their responses - new SlidingWindowCompactionStrategy(maximumTurns: 4), + new SlidingWindowCompactionStrategy(CompactionTriggers.TurnsExceed(4)), // 4. Emergency: drop oldest groups until under the token budget new TruncationCompactionStrategy(CompactionTriggers.TokensExceed(0x8000))); diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/SlidingWindowCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/SlidingWindowCompactionStrategy.cs index a811da19f7..6a7f2e5c73 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/SlidingWindowCompactionStrategy.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/SlidingWindowCompactionStrategy.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; @@ -7,20 +8,18 @@ namespace Microsoft.Agents.AI.Compaction; /// -/// A compaction strategy that keeps only the most recent user turns and their -/// associated response groups, removing older turns to bound conversation length. +/// A compaction strategy that removes the oldest user turns and their associated response groups +/// to bound conversation length. /// /// /// /// This strategy always preserves system messages. It identifies user turns in the -/// conversation (via ) and keeps the last -/// turns along with all response groups (assistant replies, -/// tool call groups) that belong to each kept turn. +/// conversation (via ) and excludes the oldest turns +/// one at a time until the condition is met. /// /// -/// The predicate controls when compaction proceeds. -/// When , a default trigger of -/// with is used. +/// is a hard floor: even if the +/// has not been reached, compaction will not touch the last non-system groups. /// /// /// This strategy is more predictable than token-based truncation for bounding conversation @@ -30,62 +29,87 @@ namespace Microsoft.Agents.AI.Compaction; public sealed class SlidingWindowCompactionStrategy : CompactionStrategy { /// - /// The default maximum number of user turns to retain before compaction occurs. This default is a reasonable starting point - /// for many conversations, but should be tuned based on the expected conversation length and token budget. + /// The default minimum number of most-recent non-system groups to preserve. /// - public const int DefaultMaximumTurns = 32; + public const int DefaultMinimumPreserved = 1; /// /// Initializes a new instance of the class. /// - /// - /// The maximum number of user turns to keep. Older turns and their associated responses are removed. + /// + /// The that controls when compaction proceeds. + /// Use for turn-based thresholds. + /// + /// + /// The minimum number of most-recent non-system message groups to preserve. + /// This is a hard floor — compaction will not exclude groups beyond this limit, + /// regardless of the target condition. /// /// /// An optional target condition that controls when compaction stops. When , - /// defaults to the inverse of the auto-derived trigger — compaction stops as soon as the turn count is within bounds. + /// defaults to the inverse of the — compaction stops as soon as the trigger would no longer fire. /// - public SlidingWindowCompactionStrategy(int maximumTurns = DefaultMaximumTurns, CompactionTrigger? target = null) - : base(CompactionTriggers.TurnsExceed(maximumTurns), target) + public SlidingWindowCompactionStrategy(CompactionTrigger trigger, int minimumPreserved = DefaultMinimumPreserved, CompactionTrigger? target = null) + : base(trigger, target) { - this.MaxTurns = maximumTurns; + this.MinimumPreserved = minimumPreserved; } /// - /// Gets the maximum number of user turns to retain after compaction. + /// 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. /// - public int MaxTurns { get; } + public int MinimumPreserved { get; } /// protected override Task ApplyCompactionAsync(MessageIndex index, CancellationToken cancellationToken) { - // Collect distinct included turn indices in order - List includedTurns = []; + // Identify protected groups: the N most-recent non-system, non-excluded groups + List nonSystemIncludedIndices = []; foreach (MessageGroup group in index.Groups) { - if (!group.IsExcluded && group.TurnIndex is int turnIndex && !includedTurns.Contains(turnIndex)) + if (!group.IsExcluded && group.Kind != MessageGroupKind.System) { - includedTurns.Add(turnIndex); + nonSystemIncludedIndices.Add(index.Groups.IndexOf(group)); } } - if (includedTurns.Count <= this.MaxTurns) + int protectedStart = Math.Max(0, nonSystemIncludedIndices.Count - this.MinimumPreserved); + HashSet protectedGroupIndices = []; + for (int i = protectedStart; i < nonSystemIncludedIndices.Count; i++) + { + protectedGroupIndices.Add(nonSystemIncludedIndices[i]); + } + + // Collect distinct included turn indices in order (oldest first), excluding protected groups + List excludableTurns = []; + for (int i = 0; i < index.Groups.Count; i++) { - return Task.FromResult(false); + MessageGroup group = index.Groups[i]; + if (!group.IsExcluded + && group.Kind != MessageGroupKind.System + && !protectedGroupIndices.Contains(i) + && group.TurnIndex is int turnIndex + && !excludableTurns.Contains(turnIndex)) + { + excludableTurns.Add(turnIndex); + } } // Exclude one turn at a time from oldest, re-checking target after each - int turnsToRemove = includedTurns.Count - this.MaxTurns; bool compacted = false; - for (int t = 0; t < turnsToRemove; t++) + for (int t = 0; t < excludableTurns.Count; t++) { - int turnToExclude = includedTurns[t]; + int turnToExclude = excludableTurns[t]; for (int i = 0; i < index.Groups.Count; i++) { MessageGroup group = index.Groups[i]; - if (!group.IsExcluded && group.Kind != MessageGroupKind.System && group.TurnIndex == turnToExclude) + if (!group.IsExcluded + && group.Kind != MessageGroupKind.System + && !protectedGroupIndices.Contains(i) + && group.TurnIndex == turnToExclude) { group.IsExcluded = true; group.ExcludeReason = $"Excluded by {nameof(SlidingWindowCompactionStrategy)}"; diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/SummarizationCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/SummarizationCompactionStrategy.cs index 3ec645e372..0e7c01719c 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/SummarizationCompactionStrategy.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/SummarizationCompactionStrategy.cs @@ -16,12 +16,16 @@ namespace Microsoft.Agents.AI.Compaction; /// /// /// -/// This strategy protects system messages and the most recent +/// This strategy protects system messages and the most recent /// non-system groups. All older groups are collected and sent to the /// for summarization. The resulting summary replaces those messages as a single assistant message /// with . /// /// +/// is a hard floor: even if the +/// has not been reached, compaction will not touch the last non-system groups. +/// +/// /// The predicate controls when compaction proceeds. /// When , the strategy compacts whenever there are groups older than the preserve window. /// Use for common trigger conditions such as token thresholds. @@ -50,9 +54,10 @@ Omit pleasantries and redundant exchanges. Be factual and brief. /// /// The that controls when compaction proceeds. /// - /// - /// The number of most-recent non-system message groups to protect from summarization. - /// Defaults to 4, preserving the current and recent exchanges. + /// + /// 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 4, preserving the current and recent exchanges. /// /// /// An optional custom system prompt for the summarization LLM call. When , @@ -65,13 +70,13 @@ Omit pleasantries and redundant exchanges. Be factual and brief. public SummarizationCompactionStrategy( IChatClient chatClient, CompactionTrigger trigger, - int preserveRecentGroups = 4, + int minimumPreserved = 4, string? summarizationPrompt = null, CompactionTrigger? target = null) : base(trigger, target) { this.ChatClient = Throw.IfNull(chatClient); - this.PreserveRecentGroups = preserveRecentGroups; + this.MinimumPreserved = minimumPreserved; this.SummarizationPrompt = summarizationPrompt ?? DefaultSummarizationPrompt; } @@ -81,9 +86,10 @@ public SummarizationCompactionStrategy( public IChatClient ChatClient { get; } /// - /// Gets the number of most-recent non-system groups to protect from summarization. + /// 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. /// - public int PreserveRecentGroups { get; } + public int MinimumPreserved { get; } /// /// Gets the prompt used when requesting summaries from the chat client. @@ -104,7 +110,7 @@ protected override async Task ApplyCompactionAsync(MessageIndex index, Can } } - int protectedFromEnd = Math.Min(this.PreserveRecentGroups, nonSystemIncludedCount); + int protectedFromEnd = Math.Min(this.MinimumPreserved, nonSystemIncludedCount); int maxSummarizable = nonSystemIncludedCount - protectedFromEnd; if (maxSummarizable <= 0) diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/ToolResultCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/ToolResultCompactionStrategy.cs index 8692e47159..a54c096cd2 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/ToolResultCompactionStrategy.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/ToolResultCompactionStrategy.cs @@ -21,6 +21,10 @@ namespace Microsoft.Agents.AI.Compaction; /// [Tool calls: get_weather, search_docs]. /// /// +/// is a hard floor: even if the +/// has not been reached, compaction will not touch the last non-system groups. +/// +/// /// The predicate controls when compaction proceeds. /// When , a default compound trigger of /// AND @@ -30,9 +34,9 @@ namespace Microsoft.Agents.AI.Compaction; public sealed class ToolResultCompactionStrategy : CompactionStrategy { /// - /// The default number of most-recent non-system groups to protect from collapsing. + /// The default minimum number of most-recent non-system groups to preserve. /// - public const int DefaultPreserveRecentGroups = 2; + public const int DefaultMinimumPreserved = 2; /// /// Initializes a new instance of the class. @@ -40,24 +44,27 @@ public sealed class ToolResultCompactionStrategy : CompactionStrategy /// /// The that controls when compaction proceeds. /// - /// - /// The number of most-recent non-system message groups to protect from collapsing. - /// Defaults to , ensuring the current turn's tool interactions remain visible. + /// + /// 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 , ensuring the current turn's tool interactions remain visible. /// /// /// An optional target condition that controls when compaction stops. When , /// defaults to the inverse of the — compaction stops as soon as the trigger would no longer fire. /// - public ToolResultCompactionStrategy(CompactionTrigger trigger, int preserveRecentGroups = DefaultPreserveRecentGroups, CompactionTrigger? target = null) + public ToolResultCompactionStrategy(CompactionTrigger trigger, int minimumPreserved = DefaultMinimumPreserved, CompactionTrigger? target = null) : base(trigger, target) { - this.PreserveRecentGroups = preserveRecentGroups; + this.MinimumPreserved = minimumPreserved; } /// - /// Gets the number of most-recent non-system groups to protect from collapsing. + /// 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. /// - public int PreserveRecentGroups { get; } + public int MinimumPreserved { get; } /// protected override Task ApplyCompactionAsync(MessageIndex index, CancellationToken cancellationToken) @@ -73,7 +80,7 @@ protected override Task ApplyCompactionAsync(MessageIndex index, Cancellat } } - int protectedStart = Math.Max(0, nonSystemIncludedIndices.Count - this.PreserveRecentGroups); + int protectedStart = Math.Max(0, nonSystemIncludedIndices.Count - this.MinimumPreserved); HashSet protectedGroupIndices = []; for (int i = protectedStart; i < nonSystemIncludedIndices.Count; i++) { diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/TruncationCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/TruncationCompactionStrategy.cs index 57a0eea528..3c7d8890f2 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/TruncationCompactionStrategy.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/TruncationCompactionStrategy.cs @@ -7,7 +7,7 @@ namespace Microsoft.Agents.AI.Compaction; /// /// A compaction strategy that removes the oldest non-system message groups, -/// keeping the most recent groups up to . +/// keeping at least most-recent groups intact. /// /// /// @@ -16,6 +16,10 @@ namespace Microsoft.Agents.AI.Compaction; /// corresponding tool result messages are always removed together. /// /// +/// is a hard floor: even if the +/// has not been reached, compaction will not touch the last non-system groups. +/// +/// /// The controls when compaction proceeds. /// Use for common trigger conditions such as token or group thresholds. /// @@ -23,9 +27,9 @@ namespace Microsoft.Agents.AI.Compaction; public sealed class TruncationCompactionStrategy : CompactionStrategy { /// - /// The default number of most-recent non-system groups to protect from collapsing. + /// The default minimum number of most-recent non-system groups to preserve. /// - public const int DefaultPreserveRecentGroups = 32; + public const int DefaultMinimumPreserved = 32; /// /// Initializes a new instance of the class. @@ -33,24 +37,26 @@ public sealed class TruncationCompactionStrategy : CompactionStrategy /// /// The that controls when compaction proceeds. /// - /// - /// The minimum number of most-recent non-system message groups to keep. - /// Defaults to 1 so that at least the latest exchange is always preserved. + /// + /// 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. /// /// /// An optional target condition that controls when compaction stops. When , /// defaults to the inverse of the — compaction stops as soon as the trigger would no longer fire. /// - public TruncationCompactionStrategy(CompactionTrigger trigger, int preserveRecentGroups = DefaultPreserveRecentGroups, CompactionTrigger? target = null) + public TruncationCompactionStrategy(CompactionTrigger trigger, int minimumPreserved = DefaultMinimumPreserved, CompactionTrigger? target = null) : base(trigger, target) { - this.PreserveRecentGroups = preserveRecentGroups; + this.MinimumPreserved = minimumPreserved; } /// - /// Gets the minimum number of most-recent non-system message groups to retain after compaction. + /// 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. /// - public int PreserveRecentGroups { get; } + public int MinimumPreserved { get; } /// protected override Task ApplyCompactionAsync(MessageIndex index, CancellationToken cancellationToken) @@ -66,7 +72,7 @@ protected override Task ApplyCompactionAsync(MessageIndex index, Cancellat } } - int maxRemovable = removableCount - this.PreserveRecentGroups; + int maxRemovable = removableCount - this.MinimumPreserved; if (maxRemovable <= 0) { return Task.FromResult(false); diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/SlidingWindowCompactionStrategyTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/SlidingWindowCompactionStrategyTests.cs index 74d057e9f0..2d78c2e720 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/SlidingWindowCompactionStrategyTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/SlidingWindowCompactionStrategyTests.cs @@ -15,8 +15,8 @@ public class SlidingWindowCompactionStrategyTests [Fact] public async Task CompactAsyncBelowMaxTurnsReturnsFalseAsync() { - // Arrange - SlidingWindowCompactionStrategy strategy = new(maximumTurns: 3); + // Arrange — trigger requires > 3 turns, conversation has 2 + SlidingWindowCompactionStrategy strategy = new(CompactionTriggers.TurnsExceed(3)); MessageIndex groups = MessageIndex.Create( [ new ChatMessage(ChatRole.User, "Q1"), @@ -35,8 +35,8 @@ public async Task CompactAsyncBelowMaxTurnsReturnsFalseAsync() [Fact] public async Task CompactAsyncExceedsMaxTurnsExcludesOldestTurnsAsync() { - // Arrange — keep 2 turns, conversation has 3 - SlidingWindowCompactionStrategy strategy = new(maximumTurns: 2); + // Arrange — trigger on > 2 turns, conversation has 3 + SlidingWindowCompactionStrategy strategy = new(CompactionTriggers.TurnsExceed(2)); MessageIndex groups = MessageIndex.Create( [ new ChatMessage(ChatRole.User, "Q1"), @@ -65,8 +65,8 @@ public async Task CompactAsyncExceedsMaxTurnsExcludesOldestTurnsAsync() [Fact] public async Task CompactAsyncPreservesSystemMessagesAsync() { - // Arrange - SlidingWindowCompactionStrategy strategy = new(maximumTurns: 1); + // Arrange — trigger on > 1 turn + SlidingWindowCompactionStrategy strategy = new(CompactionTriggers.TurnsExceed(1)); MessageIndex groups = MessageIndex.Create( [ new ChatMessage(ChatRole.System, "You are helpful."), @@ -89,8 +89,8 @@ public async Task CompactAsyncPreservesSystemMessagesAsync() [Fact] public async Task CompactAsyncPreservesToolCallGroupsInKeptTurnsAsync() { - // Arrange - SlidingWindowCompactionStrategy strategy = new(maximumTurns: 1); + // Arrange — trigger on > 1 turn + SlidingWindowCompactionStrategy strategy = new(CompactionTriggers.TurnsExceed(1)); MessageIndex groups = MessageIndex.Create( [ new ChatMessage(ChatRole.User, "Q1"), @@ -114,10 +114,10 @@ public async Task CompactAsyncPreservesToolCallGroupsInKeptTurnsAsync() } [Fact] - public async Task CompactAsyncCustomTriggerOverridesDefaultAsync() + public async Task CompactAsyncTriggerNotMetReturnsFalseAsync() { - // Arrange — custom trigger: only compact when tokens exceed threshold - SlidingWindowCompactionStrategy strategy = new(maximumTurns: 99); + // Arrange — trigger requires > 99 turns + SlidingWindowCompactionStrategy strategy = new(CompactionTriggers.TurnsExceed(99)); MessageIndex groups = MessageIndex.Create( [ @@ -126,7 +126,7 @@ public async Task CompactAsyncCustomTriggerOverridesDefaultAsync() new ChatMessage(ChatRole.User, "Q3"), ]); - // Act — tokens are tiny, trigger not met + // Act bool result = await strategy.CompactAsync(groups); // Assert @@ -136,8 +136,8 @@ public async Task CompactAsyncCustomTriggerOverridesDefaultAsync() [Fact] public async Task CompactAsyncIncludedMessagesContainOnlyKeptTurnsAsync() { - // Arrange - SlidingWindowCompactionStrategy strategy = new(maximumTurns: 1); + // Arrange — trigger on > 1 turn + SlidingWindowCompactionStrategy strategy = new(CompactionTriggers.TurnsExceed(1)); MessageIndex groups = MessageIndex.Create( [ new ChatMessage(ChatRole.System, "System"), @@ -161,13 +161,13 @@ public async Task CompactAsyncIncludedMessagesContainOnlyKeptTurnsAsync() [Fact] public async Task CompactAsyncCustomTargetStopsExcludingEarlyAsync() { - // Arrange — 4 turns, maxTurns=1 means 3 should be excluded - // But custom target stops after removing 1 turn + // Arrange — trigger on > 1 turn, custom target stops after removing 1 turn int removeCount = 0; CompactionTrigger targetAfterOne = _ => ++removeCount >= 1; SlidingWindowCompactionStrategy strategy = new( - maximumTurns: 1, + CompactionTriggers.TurnsExceed(1), + minimumPreserved: 0, target: targetAfterOne); MessageIndex index = MessageIndex.Create( @@ -191,4 +191,34 @@ public async Task CompactAsyncCustomTargetStopsExcludingEarlyAsync() Assert.False(index.Groups[2].IsExcluded); // Q2 (turn 2) — kept Assert.False(index.Groups[3].IsExcluded); // A2 (turn 2) } + + [Fact] + public async Task CompactAsyncMinimumPreservedStopsCompactionAsync() + { + // Arrange — always trigger with never-satisfied target, but MinimumPreserved = 2 is hard floor + SlidingWindowCompactionStrategy strategy = new( + CompactionTriggers.TurnsExceed(1), + minimumPreserved: 2, + target: _ => false); + + MessageIndex index = MessageIndex.Create( + [ + 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"), + new ChatMessage(ChatRole.Assistant, "A3"), + ]); + + // Act + bool result = await strategy.CompactAsync(index); + + // Assert — target never says stop, but MinimumPreserved=2 prevents removing the last 2 groups + Assert.True(result); + Assert.Equal(2, index.IncludedGroupCount); + // Last 2 non-system groups must be preserved + Assert.False(index.Groups[4].IsExcluded); // Q3 + Assert.False(index.Groups[5].IsExcluded); // A3 + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/SummarizationCompactionStrategyTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/SummarizationCompactionStrategyTests.cs index a71fc4697f..d12d490dd4 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/SummarizationCompactionStrategyTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/SummarizationCompactionStrategyTests.cs @@ -38,7 +38,7 @@ public async Task CompactAsyncTriggerNotMetReturnsFalseAsync() SummarizationCompactionStrategy strategy = new( CreateMockChatClient(), CompactionTriggers.TokensExceed(100000), - preserveRecentGroups: 1); + minimumPreserved: 1); MessageIndex index = MessageIndex.Create( [ @@ -61,7 +61,7 @@ public async Task CompactAsyncSummarizesOldGroupsAsync() SummarizationCompactionStrategy strategy = new( CreateMockChatClient("Key facts from earlier."), AlwaysTrigger, - preserveRecentGroups: 1); + minimumPreserved: 1); MessageIndex index = MessageIndex.Create( [ @@ -92,7 +92,7 @@ public async Task CompactAsyncPreservesSystemMessagesAsync() SummarizationCompactionStrategy strategy = new( CreateMockChatClient(), AlwaysTrigger, - preserveRecentGroups: 1); + minimumPreserved: 1); MessageIndex index = MessageIndex.Create( [ @@ -119,7 +119,7 @@ public async Task CompactAsyncInsertsSummaryGroupAtCorrectPositionAsync() SummarizationCompactionStrategy strategy = new( CreateMockChatClient("Summary text."), AlwaysTrigger, - preserveRecentGroups: 1); + minimumPreserved: 1); MessageIndex index = MessageIndex.Create( [ @@ -146,7 +146,7 @@ public async Task CompactAsyncHandlesEmptyLlmResponseAsync() SummarizationCompactionStrategy strategy = new( CreateMockChatClient(" "), AlwaysTrigger, - preserveRecentGroups: 1); + minimumPreserved: 1); MessageIndex index = MessageIndex.Create( [ @@ -169,7 +169,7 @@ public async Task CompactAsyncNothingToSummarizeReturnsFalseAsync() SummarizationCompactionStrategy strategy = new( CreateMockChatClient(), AlwaysTrigger, - preserveRecentGroups: 5); + minimumPreserved: 5); MessageIndex index = MessageIndex.Create( [ @@ -202,7 +202,7 @@ public async Task CompactAsyncUsesCustomPromptAsync() SummarizationCompactionStrategy strategy = new( mockClient.Object, AlwaysTrigger, - preserveRecentGroups: 1, + minimumPreserved: 1, summarizationPrompt: customPrompt); MessageIndex index = MessageIndex.Create( @@ -226,7 +226,7 @@ public async Task CompactAsyncSetsExcludeReasonAsync() SummarizationCompactionStrategy strategy = new( CreateMockChatClient(), AlwaysTrigger, - preserveRecentGroups: 1); + minimumPreserved: 1); MessageIndex index = MessageIndex.Create( [ @@ -253,7 +253,7 @@ public async Task CompactAsyncTargetStopsMarkingEarlyAsync() SummarizationCompactionStrategy strategy = new( CreateMockChatClient("Partial summary."), AlwaysTrigger, - preserveRecentGroups: 1, + minimumPreserved: 1, target: targetAfterOne); MessageIndex index = MessageIndex.Create( @@ -279,7 +279,7 @@ public async Task CompactAsyncPreservesMultipleRecentGroupsAsync() SummarizationCompactionStrategy strategy = new( CreateMockChatClient("Summary."), AlwaysTrigger, - preserveRecentGroups: 2); + minimumPreserved: 2); MessageIndex index = MessageIndex.Create( [ diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/ToolResultCompactionStrategyTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/ToolResultCompactionStrategyTests.cs index 39c03f2631..66300e69f0 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/ToolResultCompactionStrategyTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/ToolResultCompactionStrategyTests.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System.Collections.Generic; using System.Threading.Tasks; @@ -41,7 +41,7 @@ public async Task CompactAsyncCollapsesOldToolGroupsAsync() // Arrange — always trigger ToolResultCompactionStrategy strategy = new( trigger: _ => true, - preserveRecentGroups: 1); + minimumPreserved: 1); MessageIndex groups = MessageIndex.Create( [ @@ -71,7 +71,7 @@ public async Task CompactAsyncPreservesRecentToolGroupsAsync() // Arrange — protect 2 recent non-system groups (the tool group + Q2) ToolResultCompactionStrategy strategy = new( trigger: _ => true, - preserveRecentGroups: 3); + minimumPreserved: 3); MessageIndex groups = MessageIndex.Create( [ @@ -94,7 +94,7 @@ public async Task CompactAsyncPreservesSystemMessagesAsync() // Arrange ToolResultCompactionStrategy strategy = new( trigger: _ => true, - preserveRecentGroups: 1); + minimumPreserved: 1); MessageIndex groups = MessageIndex.Create( [ @@ -119,7 +119,7 @@ public async Task CompactAsyncExtractsMultipleToolNamesAsync() // Arrange — assistant calls two tools ToolResultCompactionStrategy strategy = new( trigger: _ => true, - preserveRecentGroups: 1); + minimumPreserved: 1); ChatMessage multiToolCall = new(ChatRole.Assistant, [ @@ -152,7 +152,7 @@ public async Task CompactAsyncNoToolGroupsReturnsFalseAsync() // Arrange — trigger fires but no tool groups to collapse ToolResultCompactionStrategy strategy = new( trigger: _ => true, - preserveRecentGroups: 0); + minimumPreserved: 0); MessageIndex groups = MessageIndex.Create( [ @@ -175,7 +175,7 @@ public async Task CompactAsyncCompoundTriggerRequiresTokensAndToolCallsAsync() CompactionTriggers.All( CompactionTriggers.TokensExceed(0), CompactionTriggers.HasToolCalls()), - preserveRecentGroups: 1); + minimumPreserved: 1); MessageIndex groups = MessageIndex.Create( [ @@ -201,7 +201,7 @@ public async Task CompactAsyncTargetStopsCollapsingEarlyAsync() ToolResultCompactionStrategy strategy = new( trigger: _ => true, - preserveRecentGroups: 1, + minimumPreserved: 1, target: targetAfterOne); MessageIndex index = MessageIndex.Create( diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/TruncationCompactionStrategyTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/TruncationCompactionStrategyTests.cs index 1799758ddd..ee08611653 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/TruncationCompactionStrategyTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/TruncationCompactionStrategyTests.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System.Linq; using System.Threading.Tasks; @@ -18,7 +18,7 @@ public class TruncationCompactionStrategyTests public async Task CompactAsyncAlwaysTriggerCompactsToPreserveRecentAsync() { // Arrange — always-trigger means always compact - TruncationCompactionStrategy strategy = new(s_alwaysTrigger, preserveRecentGroups: 1); + TruncationCompactionStrategy strategy = new(s_alwaysTrigger, minimumPreserved: 1); MessageIndex groups = MessageIndex.Create( [ new ChatMessage(ChatRole.User, "First"), @@ -39,7 +39,7 @@ public async Task CompactAsyncTriggerNotMetReturnsFalseAsync() { // Arrange — trigger requires > 1000 tokens, conversation is tiny TruncationCompactionStrategy strategy = new( - preserveRecentGroups: 1, + minimumPreserved: 1, trigger: CompactionTriggers.TokensExceed(1000)); MessageIndex groups = MessageIndex.Create( @@ -61,7 +61,7 @@ public async Task CompactAsyncTriggerMetExcludesOldestGroupsAsync() { // Arrange — trigger on groups > 2 TruncationCompactionStrategy strategy = new( - preserveRecentGroups: 1, + minimumPreserved: 1, trigger: CompactionTriggers.GroupsExceed(2)); MessageIndex groups = MessageIndex.Create( @@ -89,7 +89,7 @@ public async Task CompactAsyncTriggerMetExcludesOldestGroupsAsync() public async Task CompactAsyncPreservesSystemMessagesAsync() { // Arrange - TruncationCompactionStrategy strategy = new(s_alwaysTrigger, preserveRecentGroups: 1); + TruncationCompactionStrategy strategy = new(s_alwaysTrigger, minimumPreserved: 1); MessageIndex groups = MessageIndex.Create( [ new ChatMessage(ChatRole.System, "You are helpful."), @@ -117,7 +117,7 @@ public async Task CompactAsyncPreservesSystemMessagesAsync() public async Task CompactAsyncPreservesToolCallGroupAtomicityAsync() { // Arrange - TruncationCompactionStrategy strategy = new(s_alwaysTrigger, preserveRecentGroups: 1); + TruncationCompactionStrategy strategy = new(s_alwaysTrigger, minimumPreserved: 1); ChatMessage assistantToolCall= new(ChatRole.Assistant, [new FunctionCallContent("call1", "get_weather")]); ChatMessage toolResult = new(ChatRole.Tool, "Sunny"); @@ -141,7 +141,7 @@ public async Task CompactAsyncPreservesToolCallGroupAtomicityAsync() public async Task CompactAsyncSetsExcludeReasonAsync() { // Arrange - TruncationCompactionStrategy strategy = new(s_alwaysTrigger, preserveRecentGroups: 1); + TruncationCompactionStrategy strategy = new(s_alwaysTrigger, minimumPreserved: 1); MessageIndex groups = MessageIndex.Create( [ new ChatMessage(ChatRole.User, "Old"), @@ -160,7 +160,7 @@ public async Task CompactAsyncSetsExcludeReasonAsync() public async Task CompactAsyncSkipsAlreadyExcludedGroupsAsync() { // Arrange - TruncationCompactionStrategy strategy = new(s_alwaysTrigger, preserveRecentGroups: 1); + TruncationCompactionStrategy strategy = new(s_alwaysTrigger, minimumPreserved: 1); MessageIndex groups = MessageIndex.Create( [ new ChatMessage(ChatRole.User, "Already excluded"), @@ -180,10 +180,10 @@ public async Task CompactAsyncSkipsAlreadyExcludedGroupsAsync() } [Fact] - public async Task CompactAsyncPreserveRecentGroupsKeepsMultipleAsync() + public async Task CompactAsyncMinimumPreservedKeepsMultipleAsync() { // Arrange — keep 2 most recent - TruncationCompactionStrategy strategy = new(s_alwaysTrigger, preserveRecentGroups: 2); + TruncationCompactionStrategy strategy = new(s_alwaysTrigger, minimumPreserved: 2); MessageIndex groups = MessageIndex.Create( [ new ChatMessage(ChatRole.User, "Q1"), @@ -207,7 +207,7 @@ public async Task CompactAsyncPreserveRecentGroupsKeepsMultipleAsync() public async Task CompactAsyncNothingToRemoveReturnsFalseAsync() { // Arrange — preserve 5 but only 2 groups - TruncationCompactionStrategy strategy = new(s_alwaysTrigger, preserveRecentGroups: 5); + TruncationCompactionStrategy strategy = new(s_alwaysTrigger, minimumPreserved: 5); MessageIndex groups = MessageIndex.Create( [ new ChatMessage(ChatRole.User, "Hello"), @@ -230,7 +230,7 @@ public async Task CompactAsyncCustomTargetStopsEarlyAsync() TruncationCompactionStrategy strategy = new( s_alwaysTrigger, - preserveRecentGroups: 1, + minimumPreserved: 1, target: targetAfterOne); MessageIndex groups = MessageIndex.Create( @@ -258,7 +258,7 @@ public async Task CompactAsyncIncrementalStopsAtTargetAsync() // Arrange — trigger on groups > 2, target is default (inverse of trigger: groups <= 2) TruncationCompactionStrategy strategy = new( CompactionTriggers.GroupsExceed(2), - preserveRecentGroups: 1); + minimumPreserved: 1); MessageIndex groups = MessageIndex.Create( [ From 06f55c0494507ad1523b2056cb4ead7745c5125a Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Thu, 5 Mar 2026 08:10:16 -0800 Subject: [PATCH 12/22] Update tests --- .../Compaction/MessageIndex.cs | 10 +- .../SummarizationCompactionStrategy.cs | 14 +- .../Compaction/CompactingChatClientTests.cs | 400 ++++++++++++++++++ .../Compaction/MessageIndexTests.cs | 171 ++++++++ .../SlidingWindowCompactionStrategyTests.cs | 6 +- .../SummarizationCompactionStrategyTests.cs | 32 +- .../TruncationCompactionStrategyTests.cs | 26 +- .../Microsoft.Agents.AI.UnitTests.csproj | 1 + 8 files changed, 606 insertions(+), 54 deletions(-) create mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactingChatClientTests.cs diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/MessageIndex.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/MessageIndex.cs index f8467eb3ce..dff5b4764f 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/MessageIndex.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/MessageIndex.cs @@ -67,11 +67,12 @@ public MessageIndex(IList groups, Tokenizer? tokenizer = null) this.Groups = groups; this.Tokenizer = tokenizer; + // Restore turn counter from the last group that has a TurnIndex for (int index = groups.Count - 1; index >= 0; --index) { - if (this.Groups[0].TurnIndex.HasValue) + if (this.Groups[index].TurnIndex.HasValue) { - this._currentTurn = this.Groups[0].TurnIndex!.Value; + this._currentTurn = this.Groups[index].TurnIndex!.Value; break; } } @@ -353,11 +354,6 @@ private static MessageGroup CreateGroup(MessageGroupKind kind, IReadOnlyList ApplyCompactionAsync(MessageIndex index, Can } } - if (summarized == 0) - { - return false; - } - // Generate summary using the chat client (single LLM call for all marked groups) ChatResponse response = await this.ChatClient.GetResponseAsync( [ @@ -180,14 +175,7 @@ .. index.Groups ChatMessage summaryMessage = new(ChatRole.Assistant, $"[Summary]\n{summaryText}"); (summaryMessage.AdditionalProperties ??= [])[MessageGroup.SummaryPropertyKey] = true; - if (insertIndex >= 0) - { - index.InsertGroup(insertIndex, MessageGroupKind.Summary, [summaryMessage]); - } - else - { - index.AddGroup(MessageGroupKind.Summary, [summaryMessage]); - } + index.InsertGroup(insertIndex, MessageGroupKind.Summary, [summaryMessage]); return true; } diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactingChatClientTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactingChatClientTests.cs new file mode 100644 index 0000000000..80292c7ed8 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactingChatClientTests.cs @@ -0,0 +1,400 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Compaction; +using Microsoft.Extensions.AI; +using Moq; + +namespace Microsoft.Agents.AI.UnitTests.Compaction; + +/// +/// Contains tests for the class. +/// +public sealed class CompactingChatClientTests : IDisposable +{ + /// + /// Restores the static after each test. + /// + public void Dispose() + { + SetCurrentRunContext(null); + } + + [Fact] + public void ConstructorThrowsOnNullStrategyAsync() + { + Mock mockInner = new(); + Assert.Throws(() => new CompactingChatClient(mockInner.Object, null!)); + } + + [Fact] + public async Task GetResponseAsyncNoContextPassesThroughAsync() + { + // Arrange — no CurrentRunContext set → passthrough + ChatResponse expectedResponse = new([new ChatMessage(ChatRole.Assistant, "Hi")]); + Mock mockInner = new(); + mockInner.Setup(c => c.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(expectedResponse); + + TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(100000)); + CompactingChatClient client = new(mockInner.Object, strategy); + + List messages = + [ + new ChatMessage(ChatRole.User, "Hello"), + ]; + + // Act + ChatResponse response = await client.GetResponseAsync(messages); + + // Assert + Assert.Same(expectedResponse, response); + mockInner.Verify(c => c.GetResponseAsync( + messages, + It.IsAny(), + It.IsAny()), Times.Once); + } + + [Fact] + public async Task GetResponseAsyncWithContextAppliesCompactionAsync() + { + // Arrange — set CurrentRunContext so compaction runs + ChatResponse expectedResponse = new([new ChatMessage(ChatRole.Assistant, "Done")]); + List? capturedMessages = null; + Mock mockInner = new(); + mockInner.Setup(c => c.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Callback, ChatOptions?, CancellationToken>((msgs, _, _) => + capturedMessages = [.. msgs]) + .ReturnsAsync(expectedResponse); + + // Strategy that always triggers and keeps only 1 group + TruncationCompactionStrategy strategy = new(_ => true, minimumPreserved: 1); + CompactingChatClient client = new(mockInner.Object, strategy); + + TestAgentSession session = new(); + SetRunContext(session); + + List messages = + [ + new ChatMessage(ChatRole.User, "Q1"), + new ChatMessage(ChatRole.Assistant, "A1"), + new ChatMessage(ChatRole.User, "Q2"), + ]; + + // Act + ChatResponse response = await client.GetResponseAsync(messages); + + // Assert — compaction should have removed oldest groups + Assert.Same(expectedResponse, response); + Assert.NotNull(capturedMessages); + Assert.True(capturedMessages!.Count < messages.Count); + } + + [Fact] + public async Task GetResponseAsyncNoCompactionNeededReturnsOriginalMessagesAsync() + { + // Arrange — trigger never fires → no compaction + ChatResponse expectedResponse = new([new ChatMessage(ChatRole.Assistant, "Hi")]); + List? capturedMessages = null; + Mock mockInner = new(); + mockInner.Setup(c => c.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Callback, ChatOptions?, CancellationToken>((msgs, _, _) => + capturedMessages = [.. msgs]) + .ReturnsAsync(expectedResponse); + + TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(100000)); + CompactingChatClient client = new(mockInner.Object, strategy); + + TestAgentSession session = new(); + SetRunContext(session); + + List messages = + [ + new ChatMessage(ChatRole.User, "Hello"), + ]; + + // Act + await client.GetResponseAsync(messages); + + // Assert — original messages passed through + Assert.NotNull(capturedMessages); + Assert.Single(capturedMessages!); + Assert.Equal("Hello", capturedMessages[0].Text); + } + + [Fact] + public async Task GetResponseAsyncWithExistingIndexUpdatesAsync() + { + // Arrange — call twice to exercise the "existing index" path (state.MessageIndex.Count > 0) + Mock mockInner = new(); + mockInner.Setup(c => c.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new ChatResponse([new ChatMessage(ChatRole.Assistant, "OK")])); + + // Strategy that always triggers, keeping 1 group + TruncationCompactionStrategy strategy = new(_ => true, minimumPreserved: 1); + CompactingChatClient client = new(mockInner.Object, strategy); + + TestAgentSession session = new(); + SetRunContext(session); + + List messages1 = + [ + new ChatMessage(ChatRole.User, "Q1"), + new ChatMessage(ChatRole.Assistant, "A1"), + new ChatMessage(ChatRole.User, "Q2"), + ]; + + // First call — initializes state + await client.GetResponseAsync(messages1); + + List messages2 = + [ + 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 — second call exercises the update path + ChatResponse response = await client.GetResponseAsync(messages2); + + // Assert + Assert.NotNull(response); + } + + [Fact] + public async Task GetResponseAsyncNullSessionReturnsOriginalAsync() + { + // Arrange — CurrentRunContext exists but Session is null + ChatResponse expectedResponse = new([new ChatMessage(ChatRole.Assistant, "Hi")]); + Mock mockInner = new(); + mockInner.Setup(c => c.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(expectedResponse); + + TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(100000)); + CompactingChatClient client = new(mockInner.Object, strategy); + + // Set context with null session + SetRunContext(null); + + List messages = [new ChatMessage(ChatRole.User, "Hello")]; + + // Act + ChatResponse response = await client.GetResponseAsync(messages); + + // Assert + Assert.Same(expectedResponse, response); + } + + [Fact] + public async Task GetStreamingResponseAsyncNoContextPassesThroughAsync() + { + // Arrange — no CurrentRunContext + Mock mockInner = new(); + ChatResponseUpdate[] updates = [new(ChatRole.Assistant, "Hi")]; + mockInner.Setup(c => c.GetStreamingResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Returns(ToAsyncEnumerableAsync(updates)); + + TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(100000)); + CompactingChatClient client = new(mockInner.Object, strategy); + + List messages = [new ChatMessage(ChatRole.User, "Hello")]; + + // Act + List results = []; + await foreach (ChatResponseUpdate update in client.GetStreamingResponseAsync(messages)) + { + results.Add(update); + } + + // Assert + Assert.Single(results); + Assert.Equal("Hi", results[0].Text); + } + + [Fact] + public async Task GetStreamingResponseAsyncWithContextAppliesCompactionAsync() + { + // Arrange + Mock mockInner = new(); + ChatResponseUpdate[] updates = [new(ChatRole.Assistant, "Done")]; + mockInner.Setup(c => c.GetStreamingResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Returns(ToAsyncEnumerableAsync(updates)); + + TruncationCompactionStrategy strategy = new(_ => true, minimumPreserved: 1); + CompactingChatClient client = new(mockInner.Object, strategy); + + TestAgentSession session = new(); + SetRunContext(session); + + List messages = + [ + new ChatMessage(ChatRole.User, "Q1"), + new ChatMessage(ChatRole.Assistant, "A1"), + new ChatMessage(ChatRole.User, "Q2"), + ]; + + // Act + List results = []; + await foreach (ChatResponseUpdate update in client.GetStreamingResponseAsync(messages)) + { + results.Add(update); + } + + // Assert + Assert.Single(results); + Assert.Equal("Done", results[0].Text); + } + + [Fact] + public void GetServiceReturnsStrategyForMatchingType() + { + // Arrange + Mock mockInner = new(); + TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(1000)); + CompactingChatClient client = new(mockInner.Object, strategy); + + // Act — typeof(Type).IsInstanceOfType(typeof(CompactionStrategy)) is true + object? result = client.GetService(typeof(Type)); + + // Assert + Assert.Same(strategy, result); + } + + [Fact] + public void GetServiceDelegatesToBaseForNonMatchingType() + { + // Arrange + Mock mockInner = new(); + TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(1000)); + CompactingChatClient client = new(mockInner.Object, strategy); + + // Act — typeof(string) doesn't match + object? result = client.GetService(typeof(string)); + + // Assert — delegates to base (which returns null for unregistered types) + Assert.Null(result); + } + + [Fact] + public void GetServiceThrowsOnNullType() + { + Mock mockInner = new(); + TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(1000)); + CompactingChatClient client = new(mockInner.Object, strategy); + + Assert.Throws(() => client.GetService(null!)); + } + + [Fact] + public void GetServiceWithServiceKeyDelegatesToBase() + { + // Arrange — non-null serviceKey always delegates + Mock mockInner = new(); + TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(1000)); + CompactingChatClient client = new(mockInner.Object, strategy); + + // Act + object? result = client.GetService(typeof(Type), serviceKey: "mykey"); + + // Assert — delegates to base because serviceKey is non-null + Assert.Null(result); + } + + [Fact] + public async Task GetResponseAsyncMessagesNotListCreatesListCopyAsync() + { + // Arrange — pass IEnumerable (not List) to exercise the list copy branch + ChatResponse expectedResponse = new([new ChatMessage(ChatRole.Assistant, "Hi")]); + Mock mockInner = new(); + mockInner.Setup(c => c.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(expectedResponse); + + TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(100000)); + CompactingChatClient client = new(mockInner.Object, strategy); + + TestAgentSession session = new(); + SetRunContext(session); + + // Use an IEnumerable (not a List) to trigger the copy path + IEnumerable messages = new ChatMessage[] { new(ChatRole.User, "Hello") }; + + // Act + ChatResponse response = await client.GetResponseAsync(messages); + + // Assert + Assert.Same(expectedResponse, response); + } + + /// + /// Sets via reflection. + /// + private static void SetCurrentRunContext(AgentRunContext? context) + { + FieldInfo? field = typeof(AIAgent).GetField("s_currentContext", BindingFlags.NonPublic | BindingFlags.Static); + Assert.NotNull(field); + object? asyncLocal = field!.GetValue(null); + Assert.NotNull(asyncLocal); + PropertyInfo? valueProp = asyncLocal!.GetType().GetProperty("Value"); + Assert.NotNull(valueProp); + valueProp!.SetValue(asyncLocal, context); + } + + /// + /// Creates an with the given session and sets it as the current context. + /// + private static void SetRunContext(AgentSession? session) + { + Mock mockAgent = new() { CallBase = true }; + AgentRunContext context = new( + mockAgent.Object, + session, + new List { new(ChatRole.User, "test") }, + null); + SetCurrentRunContext(context); + } + + private static async IAsyncEnumerable ToAsyncEnumerableAsync( + ChatResponseUpdate[] updates, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + foreach (ChatResponseUpdate update in updates) + { + cancellationToken.ThrowIfCancellationRequested(); + yield return update; + await Task.CompletedTask; + } + } + + private sealed class TestAgentSession : AgentSession; +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/MessageIndexTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/MessageIndexTests.cs index 9f0eabf9d8..b1fa5c59cb 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/MessageIndexTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/MessageIndexTests.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using System.Buffers; using System.Collections.Generic; using Microsoft.Agents.AI.Compaction; using Microsoft.Extensions.AI; @@ -675,4 +676,174 @@ public void InsertGroupComputesByteAndTokenCounts() Assert.Equal(5, inserted.ByteCount); Assert.Equal(1, inserted.TokenCount); // 5 / 4 = 1 (integer division) } + + [Fact] + public void ConstructorWithGroupsRestoresTurnIndex() + { + // Arrange — pre-existing groups with turn indices + MessageGroup group1 = new(MessageGroupKind.User, [new ChatMessage(ChatRole.User, "Q1")], 2, 1, turnIndex: 1); + MessageGroup group2 = new(MessageGroupKind.AssistantText, [new ChatMessage(ChatRole.Assistant, "A1")], 2, 1, turnIndex: 1); + MessageGroup group3 = new(MessageGroupKind.User, [new ChatMessage(ChatRole.User, "Q2")], 2, 1, turnIndex: 2); + List groups = [group1, group2, group3]; + + // Act — constructor should restore _currentTurn from the last group's TurnIndex + MessageIndex index = new(groups); + + // Assert — adding a new user message should get turn 3 (restored 2 + 1) + index.Update( + [ + new ChatMessage(ChatRole.User, "Q1"), + new ChatMessage(ChatRole.Assistant, "A1"), + new ChatMessage(ChatRole.User, "Q2"), + new ChatMessage(ChatRole.User, "Q3"), + ]); + + // The new user group should have TurnIndex 3 + MessageGroup lastGroup = index.Groups[index.Groups.Count - 1]; + Assert.Equal(MessageGroupKind.User, lastGroup.Kind); + Assert.NotNull(lastGroup.TurnIndex); + } + + [Fact] + public void ConstructorWithEmptyGroupsHandlesGracefully() + { + // Arrange & Act — constructor with empty list + MessageIndex index = new([]); + + // Assert + Assert.Empty(index.Groups); + } + + [Fact] + public void ConstructorWithGroupsWithoutTurnIndexSkipsRestore() + { + // Arrange — groups without turn indices (system messages) + MessageGroup systemGroup = new(MessageGroupKind.System, [new ChatMessage(ChatRole.System, "Be helpful")], 10, 3, turnIndex: null); + List groups = [systemGroup]; + + // Act — constructor won't find a TurnIndex to restore + MessageIndex index = new(groups); + + // Assert + Assert.Single(index.Groups); + } + + [Fact] + public void ComputeTokenCountReturnsTokenCount() + { + // Arrange — call the public static method directly + List messages = + [ + new ChatMessage(ChatRole.User, "Hello world"), + new ChatMessage(ChatRole.Assistant, "Greetings"), + ]; + + // Act — use a simple tokenizer that counts words (each word = 1 token) + SimpleWordTokenizer tokenizer = new(); + int tokenCount = MessageIndex.ComputeTokenCount(messages, tokenizer); + + // Assert — "Hello world" = 2, "Greetings" = 1 → 3 total + Assert.Equal(3, tokenCount); + } + + [Fact] + public void ComputeTokenCountEmptyTextReturnsZero() + { + // Arrange — message with no text content + List messages = + [ + new ChatMessage(ChatRole.User, [new FunctionCallContent("c1", "fn")]), + ]; + + SimpleWordTokenizer tokenizer = new(); + int tokenCount = MessageIndex.ComputeTokenCount(messages, tokenizer); + + // Assert — no text content → 0 tokens + Assert.Equal(0, tokenCount); + } + + [Fact] + public void CreateWithTokenizerUsesTokenizerForCounts() + { + // Arrange + SimpleWordTokenizer tokenizer = new(); + + List messages = + [ + new ChatMessage(ChatRole.User, "Hello world test"), + ]; + + // Act + MessageIndex index = MessageIndex.Create(messages, tokenizer); + + // Assert — tokenizer counts words: "Hello world test" = 3 tokens + Assert.Single(index.Groups); + Assert.Equal(3, index.Groups[0].TokenCount); + Assert.NotNull(index.Tokenizer); + } + + [Fact] + public void InsertGroupWithTokenizerUsesTokenizer() + { + // Arrange + SimpleWordTokenizer tokenizer = new(); + MessageIndex index = MessageIndex.Create( + [ + new ChatMessage(ChatRole.User, "Hello"), + ], tokenizer); + + // Act + ChatMessage msg = new(ChatRole.Assistant, "Hello world test message"); + MessageGroup inserted = index.InsertGroup(0, MessageGroupKind.AssistantText, [msg]); + + // Assert — tokenizer counts words: "Hello world test message" = 4 tokens + Assert.Equal(4, inserted.TokenCount); + } + + /// + /// A simple tokenizer that counts whitespace-separated words as tokens. + /// + private sealed class SimpleWordTokenizer : Microsoft.ML.Tokenizers.Tokenizer + { + public override Microsoft.ML.Tokenizers.PreTokenizer? PreTokenizer => null; + public override Microsoft.ML.Tokenizers.Normalizer? Normalizer => null; + + protected override Microsoft.ML.Tokenizers.EncodeResults EncodeToTokens(string? text, System.ReadOnlySpan textSpan, Microsoft.ML.Tokenizers.EncodeSettings settings) + { + // Simple word-based encoding + string input = text ?? textSpan.ToString(); + if (string.IsNullOrWhiteSpace(input)) + { + return new Microsoft.ML.Tokenizers.EncodeResults + { + Tokens = System.Array.Empty(), + CharsConsumed = 0, + NormalizedText = null, + }; + } + + string[] words = input.Split(' '); + List tokens = []; + int offset = 0; + for (int i = 0; i < words.Length; i++) + { + tokens.Add(new Microsoft.ML.Tokenizers.EncodedToken(i, words[i], new System.Range(offset, offset + words[i].Length))); + offset += words[i].Length + 1; + } + + return new Microsoft.ML.Tokenizers.EncodeResults + { + Tokens = tokens, + CharsConsumed = input.Length, + NormalizedText = null, + }; + } + + public override OperationStatus Decode(System.Collections.Generic.IEnumerable ids, System.Span destination, out int idsConsumed, out int charsWritten) + { + idsConsumed = 0; + charsWritten = 0; + return OperationStatus.Done; + } + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/SlidingWindowCompactionStrategyTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/SlidingWindowCompactionStrategyTests.cs index 2d78c2e720..08c3b50ad3 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/SlidingWindowCompactionStrategyTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/SlidingWindowCompactionStrategyTests.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System.Collections.Generic; using System.Threading.Tasks; @@ -163,12 +163,12 @@ public async Task CompactAsyncCustomTargetStopsExcludingEarlyAsync() { // Arrange — trigger on > 1 turn, custom target stops after removing 1 turn int removeCount = 0; - CompactionTrigger targetAfterOne = _ => ++removeCount >= 1; + bool TargetAfterOne(MessageIndex _) => ++removeCount >= 1; SlidingWindowCompactionStrategy strategy = new( CompactionTriggers.TurnsExceed(1), minimumPreserved: 0, - target: targetAfterOne); + target: TargetAfterOne); MessageIndex index = MessageIndex.Create( [ diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/SummarizationCompactionStrategyTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/SummarizationCompactionStrategyTests.cs index d12d490dd4..9d6bfdb05b 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/SummarizationCompactionStrategyTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/SummarizationCompactionStrategyTests.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System.Collections.Generic; using System.Linq; @@ -15,8 +15,6 @@ namespace Microsoft.Agents.AI.UnitTests.Compaction; /// public class SummarizationCompactionStrategyTests { - private static readonly CompactionTrigger AlwaysTrigger = _ => true; - /// /// Creates a mock that returns the specified summary text. /// @@ -60,7 +58,7 @@ public async Task CompactAsyncSummarizesOldGroupsAsync() // Arrange — always trigger, preserve 1 recent group SummarizationCompactionStrategy strategy = new( CreateMockChatClient("Key facts from earlier."), - AlwaysTrigger, + CompactionTriggers.Always, minimumPreserved: 1); MessageIndex index = MessageIndex.Create( @@ -91,7 +89,7 @@ public async Task CompactAsyncPreservesSystemMessagesAsync() // Arrange SummarizationCompactionStrategy strategy = new( CreateMockChatClient(), - AlwaysTrigger, + CompactionTriggers.Always, minimumPreserved: 1); MessageIndex index = MessageIndex.Create( @@ -118,7 +116,7 @@ public async Task CompactAsyncInsertsSummaryGroupAtCorrectPositionAsync() // Arrange SummarizationCompactionStrategy strategy = new( CreateMockChatClient("Summary text."), - AlwaysTrigger, + CompactionTriggers.Always, minimumPreserved: 1); MessageIndex index = MessageIndex.Create( @@ -145,7 +143,7 @@ public async Task CompactAsyncHandlesEmptyLlmResponseAsync() // Arrange — LLM returns whitespace SummarizationCompactionStrategy strategy = new( CreateMockChatClient(" "), - AlwaysTrigger, + CompactionTriggers.Always, minimumPreserved: 1); MessageIndex index = MessageIndex.Create( @@ -168,7 +166,7 @@ public async Task CompactAsyncNothingToSummarizeReturnsFalseAsync() // Arrange — preserve 5 but only 2 non-system groups SummarizationCompactionStrategy strategy = new( CreateMockChatClient(), - AlwaysTrigger, + CompactionTriggers.Always, minimumPreserved: 5); MessageIndex index = MessageIndex.Create( @@ -198,12 +196,12 @@ public async Task CompactAsyncUsesCustomPromptAsync() capturedMessages = [.. msgs]) .ReturnsAsync(new ChatResponse([new ChatMessage(ChatRole.Assistant, "Custom summary.")])); - const string customPrompt = "Summarize in bullet points only."; + const string CustomPrompt = "Summarize in bullet points only."; SummarizationCompactionStrategy strategy = new( mockClient.Object, - AlwaysTrigger, + CompactionTriggers.Always, minimumPreserved: 1, - summarizationPrompt: customPrompt); + summarizationPrompt: CustomPrompt); MessageIndex index = MessageIndex.Create( [ @@ -216,7 +214,7 @@ public async Task CompactAsyncUsesCustomPromptAsync() // Assert — the custom prompt should be the first message sent to the LLM Assert.NotNull(capturedMessages); - Assert.Equal(customPrompt, capturedMessages![0].Text); + Assert.Equal(CustomPrompt, capturedMessages![0].Text); } [Fact] @@ -225,7 +223,7 @@ public async Task CompactAsyncSetsExcludeReasonAsync() // Arrange SummarizationCompactionStrategy strategy = new( CreateMockChatClient(), - AlwaysTrigger, + CompactionTriggers.Always, minimumPreserved: 1); MessageIndex index = MessageIndex.Create( @@ -248,13 +246,13 @@ public async Task CompactAsyncTargetStopsMarkingEarlyAsync() { // Arrange — 4 non-system groups, preserve 1, target met after 1 exclusion int exclusionCount = 0; - CompactionTrigger targetAfterOne = _ => ++exclusionCount >= 1; + CompactionTrigger TargetAfterOne = _ => ++exclusionCount >= 1; SummarizationCompactionStrategy strategy = new( CreateMockChatClient("Partial summary."), - AlwaysTrigger, + CompactionTriggers.Always, minimumPreserved: 1, - target: targetAfterOne); + target: TargetAfterOne); MessageIndex index = MessageIndex.Create( [ @@ -278,7 +276,7 @@ public async Task CompactAsyncPreservesMultipleRecentGroupsAsync() // Arrange — preserve 2 SummarizationCompactionStrategy strategy = new( CreateMockChatClient("Summary."), - AlwaysTrigger, + CompactionTriggers.Always, minimumPreserved: 2); MessageIndex index = MessageIndex.Create( diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/TruncationCompactionStrategyTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/TruncationCompactionStrategyTests.cs index ee08611653..ceb40b7495 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/TruncationCompactionStrategyTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/TruncationCompactionStrategyTests.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System.Linq; using System.Threading.Tasks; @@ -12,13 +12,11 @@ namespace Microsoft.Agents.AI.UnitTests.Compaction; /// public class TruncationCompactionStrategyTests { - private static readonly CompactionTrigger s_alwaysTrigger = _ => true; - [Fact] public async Task CompactAsyncAlwaysTriggerCompactsToPreserveRecentAsync() { // Arrange — always-trigger means always compact - TruncationCompactionStrategy strategy = new(s_alwaysTrigger, minimumPreserved: 1); + TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreserved: 1); MessageIndex groups = MessageIndex.Create( [ new ChatMessage(ChatRole.User, "First"), @@ -89,7 +87,7 @@ public async Task CompactAsyncTriggerMetExcludesOldestGroupsAsync() public async Task CompactAsyncPreservesSystemMessagesAsync() { // Arrange - TruncationCompactionStrategy strategy = new(s_alwaysTrigger, minimumPreserved: 1); + TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreserved: 1); MessageIndex groups = MessageIndex.Create( [ new ChatMessage(ChatRole.System, "You are helpful."), @@ -117,9 +115,9 @@ public async Task CompactAsyncPreservesSystemMessagesAsync() public async Task CompactAsyncPreservesToolCallGroupAtomicityAsync() { // Arrange - TruncationCompactionStrategy strategy = new(s_alwaysTrigger, minimumPreserved: 1); + TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreserved: 1); - ChatMessage assistantToolCall= new(ChatRole.Assistant, [new FunctionCallContent("call1", "get_weather")]); + ChatMessage assistantToolCall = new(ChatRole.Assistant, [new FunctionCallContent("call1", "get_weather")]); ChatMessage toolResult = new(ChatRole.Tool, "Sunny"); ChatMessage finalResponse = new(ChatRole.User, "Thanks!"); @@ -141,7 +139,7 @@ public async Task CompactAsyncPreservesToolCallGroupAtomicityAsync() public async Task CompactAsyncSetsExcludeReasonAsync() { // Arrange - TruncationCompactionStrategy strategy = new(s_alwaysTrigger, minimumPreserved: 1); + TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreserved: 1); MessageIndex groups = MessageIndex.Create( [ new ChatMessage(ChatRole.User, "Old"), @@ -160,7 +158,7 @@ public async Task CompactAsyncSetsExcludeReasonAsync() public async Task CompactAsyncSkipsAlreadyExcludedGroupsAsync() { // Arrange - TruncationCompactionStrategy strategy = new(s_alwaysTrigger, minimumPreserved: 1); + TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreserved: 1); MessageIndex groups = MessageIndex.Create( [ new ChatMessage(ChatRole.User, "Already excluded"), @@ -183,7 +181,7 @@ public async Task CompactAsyncSkipsAlreadyExcludedGroupsAsync() public async Task CompactAsyncMinimumPreservedKeepsMultipleAsync() { // Arrange — keep 2 most recent - TruncationCompactionStrategy strategy = new(s_alwaysTrigger, minimumPreserved: 2); + TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreserved: 2); MessageIndex groups = MessageIndex.Create( [ new ChatMessage(ChatRole.User, "Q1"), @@ -207,7 +205,7 @@ public async Task CompactAsyncMinimumPreservedKeepsMultipleAsync() public async Task CompactAsyncNothingToRemoveReturnsFalseAsync() { // Arrange — preserve 5 but only 2 groups - TruncationCompactionStrategy strategy = new(s_alwaysTrigger, minimumPreserved: 5); + TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreserved: 5); MessageIndex groups = MessageIndex.Create( [ new ChatMessage(ChatRole.User, "Hello"), @@ -226,12 +224,12 @@ public async Task CompactAsyncCustomTargetStopsEarlyAsync() { // Arrange — always trigger, custom target stops after 1 exclusion int targetChecks = 0; - CompactionTrigger targetAfterOne = _ => ++targetChecks >= 1; + bool TargetAfterOne(MessageIndex _) => ++targetChecks >= 1; TruncationCompactionStrategy strategy = new( - s_alwaysTrigger, + CompactionTriggers.Always, minimumPreserved: 1, - target: targetAfterOne); + target: TargetAfterOne); MessageIndex groups = MessageIndex.Create( [ diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Microsoft.Agents.AI.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Microsoft.Agents.AI.UnitTests.csproj index 7fa417b184..ffa4417f34 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Microsoft.Agents.AI.UnitTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Microsoft.Agents.AI.UnitTests.csproj @@ -16,6 +16,7 @@ + From f42863e354c121940d47d90569e382b1f9a759fd Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Thu, 5 Mar 2026 08:25:19 -0800 Subject: [PATCH 13/22] Test update --- .../Compaction/CompactionTriggers.cs | 6 + .../Compaction/CompactingChatClientTests.cs | 7 +- .../Compaction/MessageIndexTests.cs | 62 ++++++++++ .../SlidingWindowCompactionStrategyTests.cs | 26 ++++ .../SummarizationCompactionStrategyTests.cs | 112 +++++++++++++++++- .../ToolResultCompactionStrategyTests.cs | 26 ++++ .../TruncationCompactionStrategyTests.cs | 51 ++++++++ 7 files changed, 285 insertions(+), 5 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionTriggers.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionTriggers.cs index 44a426f1e2..d2064c4449 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionTriggers.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionTriggers.cs @@ -20,6 +20,12 @@ public static class CompactionTriggers public static readonly CompactionTrigger Always = _ => true; + /// + /// Always trigger compaction, regardless of the message index state. + /// + public static readonly CompactionTrigger Never = + _ => false; + /// /// Creates a trigger that fires when the included token count is below the specified maximum. /// diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactingChatClientTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactingChatClientTests.cs index 80292c7ed8..c165d8c1e6 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactingChatClientTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactingChatClientTests.cs @@ -1,8 +1,7 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System; using System.Collections.Generic; -using System.Linq; using System.Reflection; using System.Runtime.CompilerServices; using System.Threading; @@ -348,7 +347,7 @@ public async Task GetResponseAsyncMessagesNotListCreatesListCopyAsync() SetRunContext(session); // Use an IEnumerable (not a List) to trigger the copy path - IEnumerable messages = new ChatMessage[] { new(ChatRole.User, "Hello") }; + IEnumerable messages = [new(ChatRole.User, "Hello")]; // Act ChatResponse response = await client.GetResponseAsync(messages); @@ -380,7 +379,7 @@ private static void SetRunContext(AgentSession? session) AgentRunContext context = new( mockAgent.Object, session, - new List { new(ChatRole.User, "test") }, + [new(ChatRole.User, "test")], null); SetCurrentRunContext(context); } diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/MessageIndexTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/MessageIndexTests.cs index b1fa5c59cb..b17b0d1b95 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/MessageIndexTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/MessageIndexTests.cs @@ -800,6 +800,68 @@ public void InsertGroupWithTokenizerUsesTokenizer() Assert.Equal(4, inserted.TokenCount); } + [Fact] + public void CreateWithStandaloneToolMessageGroupsAsAssistantText() + { + // A Tool message not preceded by an assistant tool-call falls through to the else branch + List messages = + [ + new ChatMessage(ChatRole.Tool, "Orphaned tool result"), + ]; + + MessageIndex index = MessageIndex.Create(messages); + + // The Tool message should be grouped as AssistantText (the default fallback) + Assert.Single(index.Groups); + Assert.Equal(MessageGroupKind.AssistantText, index.Groups[0].Kind); + } + + [Fact] + public void CreateWithAssistantNonSummaryWithPropertiesFallsToAssistantText() + { + // Assistant message with AdditionalProperties but NOT a summary + ChatMessage assistant = new(ChatRole.Assistant, "Regular response"); + (assistant.AdditionalProperties ??= [])["someOtherKey"] = "value"; + + MessageIndex index = MessageIndex.Create([assistant]); + + Assert.Single(index.Groups); + Assert.Equal(MessageGroupKind.AssistantText, index.Groups[0].Kind); + } + + [Fact] + public void ComputeByteCountHandlesNullAndNonNullText() + { + // Mix of messages: one with text (non-null), one without (null Text) + List messages = + [ + new ChatMessage(ChatRole.User, "Hello"), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "fn")]), + ]; + + int byteCount = MessageIndex.ComputeByteCount(messages); + + // Only "Hello" contributes bytes (5 bytes UTF-8) + Assert.Equal(5, byteCount); + } + + [Fact] + public void ComputeTokenCountHandlesNullAndNonNullText() + { + // Mix: one with text, one without + SimpleWordTokenizer tokenizer = new(); + List messages = + [ + new ChatMessage(ChatRole.User, "Hello world"), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "fn")]), + ]; + + int tokenCount = MessageIndex.ComputeTokenCount(messages, tokenizer); + + // Only "Hello world" contributes tokens (2 words) + Assert.Equal(2, tokenCount); + } + /// /// A simple tokenizer that counts whitespace-separated words as tokens. /// diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/SlidingWindowCompactionStrategyTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/SlidingWindowCompactionStrategyTests.cs index 08c3b50ad3..709a44e0a4 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/SlidingWindowCompactionStrategyTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/SlidingWindowCompactionStrategyTests.cs @@ -221,4 +221,30 @@ public async Task CompactAsyncMinimumPreservedStopsCompactionAsync() Assert.False(index.Groups[4].IsExcluded); // Q3 Assert.False(index.Groups[5].IsExcluded); // A3 } + + [Fact] + public async Task CompactAsyncSkipsExcludedAndSystemGroupsInEnumerationAsync() + { + // Arrange — includes system and pre-excluded groups that must be skipped + SlidingWindowCompactionStrategy strategy = new( + CompactionTriggers.TurnsExceed(1), + minimumPreserved: 0); + + MessageIndex index = MessageIndex.Create( + [ + new ChatMessage(ChatRole.System, "System prompt"), + new ChatMessage(ChatRole.User, "Q1"), + new ChatMessage(ChatRole.Assistant, "A1"), + new ChatMessage(ChatRole.User, "Q2"), + ]); + // Pre-exclude one group + index.Groups[1].IsExcluded = true; + + // Act + bool result = await strategy.CompactAsync(index); + + // Assert — system preserved, pre-excluded skipped + Assert.True(result); + Assert.False(index.Groups[0].IsExcluded); // System preserved + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/SummarizationCompactionStrategyTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/SummarizationCompactionStrategyTests.cs index 9d6bfdb05b..0bf225ae34 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/SummarizationCompactionStrategyTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/SummarizationCompactionStrategyTests.cs @@ -246,7 +246,7 @@ public async Task CompactAsyncTargetStopsMarkingEarlyAsync() { // Arrange — 4 non-system groups, preserve 1, target met after 1 exclusion int exclusionCount = 0; - CompactionTrigger TargetAfterOne = _ => ++exclusionCount >= 1; + bool TargetAfterOne(MessageIndex _) => ++exclusionCount >= 1; SummarizationCompactionStrategy strategy = new( CreateMockChatClient("Partial summary."), @@ -297,4 +297,114 @@ public async Task CompactAsyncPreservesMultipleRecentGroupsAsync() Assert.Equal("Q2", included[1].Text); Assert.Equal("A2", included[2].Text); } + + [Fact] + public async Task CompactAsyncWithSystemBetweenSummarizableGroupsAsync() + { + // Arrange — system group between user/assistant groups to exercise skip logic in loop + IChatClient mockClient = CreateMockChatClient("[Summary]"); + SummarizationCompactionStrategy strategy = new( + mockClient, + CompactionTriggers.Always, + minimumPreserved: 1); + + MessageIndex index = MessageIndex.Create( + [ + new ChatMessage(ChatRole.User, "Q1"), + new ChatMessage(ChatRole.System, "System note"), + new ChatMessage(ChatRole.Assistant, "A1"), + new ChatMessage(ChatRole.User, "Q2"), + ]); + + // Act + bool result = await strategy.CompactAsync(index); + + // Assert — summary inserted at 0, system group shifted to index 2 + Assert.True(result); + Assert.Equal(MessageGroupKind.Summary, index.Groups[0].Kind); + Assert.Equal(MessageGroupKind.System, index.Groups[2].Kind); + Assert.False(index.Groups[2].IsExcluded); // System never excluded + } + + [Fact] + public async Task CompactAsyncMaxSummarizableBoundsLoopExitAsync() + { + // Arrange — large MinimumPreserved so maxSummarizable is small, target never stops + IChatClient mockClient = CreateMockChatClient("[Summary]"); + SummarizationCompactionStrategy strategy = new( + mockClient, + CompactionTriggers.Always, + minimumPreserved: 3, + target: _ => false); + + MessageIndex index = MessageIndex.Create( + [ + 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"), + new ChatMessage(ChatRole.Assistant, "A3"), + ]); + + // Act — should only summarize 6-3 = 3 groups (not all 6) + bool result = await strategy.CompactAsync(index); + + // Assert — 3 preserved + 1 summary = 4 included + Assert.True(result); + Assert.Equal(4, index.IncludedGroupCount); + } + + [Fact] + public async Task CompactAsyncWithPreExcludedGroupAsync() + { + // Arrange — pre-exclude a group so the count and loop both must skip it + IChatClient mockClient = CreateMockChatClient("[Summary]"); + SummarizationCompactionStrategy strategy = new( + mockClient, + CompactionTriggers.Always, + minimumPreserved: 1); + + MessageIndex index = MessageIndex.Create( + [ + new ChatMessage(ChatRole.User, "Q1"), + new ChatMessage(ChatRole.Assistant, "A1"), + new ChatMessage(ChatRole.User, "Q2"), + new ChatMessage(ChatRole.Assistant, "A2"), + ]); + index.Groups[0].IsExcluded = true; // Pre-exclude Q1 + + // Act + bool result = await strategy.CompactAsync(index); + + // Assert + Assert.True(result); + Assert.True(index.Groups[0].IsExcluded); // Still excluded + } + + [Fact] + public async Task CompactAsyncWithEmptyTextMessageInGroupAsync() + { + // Arrange — a message with null text (FunctionCallContent) in a summarized group + IChatClient mockClient = CreateMockChatClient("[Summary]"); + SummarizationCompactionStrategy strategy = new( + mockClient, + CompactionTriggers.Always, + minimumPreserved: 1); + + List messages = + [ + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "fn")]), + new ChatMessage(ChatRole.User, "Q1"), + new ChatMessage(ChatRole.Assistant, "A1"), + ]; + + MessageIndex index = MessageIndex.Create(messages); + + // Act — the tool-call group's message has null text + bool result = await strategy.CompactAsync(index); + + // Assert — compaction succeeded despite null text + Assert.True(result); + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/ToolResultCompactionStrategyTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/ToolResultCompactionStrategyTests.cs index 66300e69f0..2b925aef76 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/ToolResultCompactionStrategyTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/ToolResultCompactionStrategyTests.cs @@ -233,4 +233,30 @@ public async Task CompactAsyncTargetStopsCollapsingEarlyAsync() Assert.Equal(1, collapsedToolGroups); } + + [Fact] + public async Task CompactAsyncSkipsPreExcludedAndSystemGroupsAsync() + { + // Arrange — pre-excluded and system groups in the enumeration + ToolResultCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreserved: 0); + + List messages = + [ + new ChatMessage(ChatRole.System, "System prompt"), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "fn")]), + new ChatMessage(ChatRole.Tool, "Result 1"), + new ChatMessage(ChatRole.User, "Q1"), + ]; + + MessageIndex index = MessageIndex.Create(messages); + // Pre-exclude the user group + index.Groups[index.Groups.Count - 1].IsExcluded = true; + + // Act + bool result = await strategy.CompactAsync(index); + + // Assert — system never excluded, pre-excluded skipped + Assert.True(result); + Assert.False(index.Groups[0].IsExcluded); // System stays + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/TruncationCompactionStrategyTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/TruncationCompactionStrategyTests.cs index ceb40b7495..2783fa029c 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/TruncationCompactionStrategyTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/TruncationCompactionStrategyTests.cs @@ -274,4 +274,55 @@ public async Task CompactAsyncIncrementalStopsAtTargetAsync() Assert.True(result); Assert.Equal(2, groups.IncludedGroupCount); } + + [Fact] + public async Task CompactAsyncLoopExitsWhenMaxRemovableReachedAsync() + { + // Arrange — target never stops (always false), so the loop must exit via removed >= maxRemovable + TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreserved: 2, target: CompactionTriggers.Never); + MessageIndex groups = MessageIndex.Create( + [ + new ChatMessage(ChatRole.User, "Q1"), + new ChatMessage(ChatRole.Assistant, "A1"), + new ChatMessage(ChatRole.User, "Q2"), + new ChatMessage(ChatRole.Assistant, "A2"), + ]); + + // Act + bool result = await strategy.CompactAsync(groups); + + // Assert — only 2 removed (maxRemovable = 4 - 2 = 2), 2 preserved + 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 CompactAsyncSkipsPreExcludedAndSystemGroupsAsync() + { + // Arrange — has excluded + system groups that the loop must skip + TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreserved: 1); + MessageIndex groups = MessageIndex.Create( + [ + new ChatMessage(ChatRole.System, "System"), + new ChatMessage(ChatRole.User, "Q1"), + new ChatMessage(ChatRole.Assistant, "A1"), + new ChatMessage(ChatRole.User, "Q2"), + ]); + // Pre-exclude one group + groups.Groups[1].IsExcluded = true; + + // Act + bool result = await strategy.CompactAsync(groups); + + // Assert — system preserved, pre-excluded skipped, A1 removed, Q2 preserved + Assert.True(result); + Assert.False(groups.Groups[0].IsExcluded); // System + Assert.True(groups.Groups[1].IsExcluded); // Pre-excluded Q1 + Assert.True(groups.Groups[2].IsExcluded); // Newly excluded A1 + Assert.False(groups.Groups[3].IsExcluded); // Preserved Q2 + } } From c513694b2f91d0ec9d2bcd634da72fe2d1374de9 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Thu, 5 Mar 2026 08:27:00 -0800 Subject: [PATCH 14/22] Remove working solution --- dotnet/agent-working-dotnet.slnx | 448 ------------------------------- 1 file changed, 448 deletions(-) delete mode 100644 dotnet/agent-working-dotnet.slnx diff --git a/dotnet/agent-working-dotnet.slnx b/dotnet/agent-working-dotnet.slnx deleted file mode 100644 index 60542e7969..0000000000 --- a/dotnet/agent-working-dotnet.slnx +++ /dev/null @@ -1,448 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file From 43d226f8caedb4f6b4248e598eddbe8344a2a12c Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Thu, 5 Mar 2026 08:32:04 -0800 Subject: [PATCH 15/22] Add sample to solution --- dotnet/agent-framework-dotnet.slnx | 1 + 1 file changed, 1 insertion(+) diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 75888768fa..6aa7337ba8 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -56,6 +56,7 @@ + From 5ef100c3a477aedbb0098f23354bf7de79d2f0f6 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Thu, 5 Mar 2026 08:35:20 -0800 Subject: [PATCH 16/22] Sample readyme --- dotnet/samples/02-agents/Agents/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/dotnet/samples/02-agents/Agents/README.md b/dotnet/samples/02-agents/Agents/README.md index 116cbfc06b..4ac53ba246 100644 --- a/dotnet/samples/02-agents/Agents/README.md +++ b/dotnet/samples/02-agents/Agents/README.md @@ -44,6 +44,7 @@ Before you begin, ensure you have the following prerequisites: |[Deep research with an agent](./Agent_Step15_DeepResearch/)|This sample demonstrates how to use the Deep Research Tool to perform comprehensive research on complex topics| |[Declarative agent](./Agent_Step16_Declarative/)|This sample demonstrates how to declaratively define an agent.| |[Providing additional AI Context to an agent using multiple AIContextProviders](./Agent_Step17_AdditionalAIContext/)|This sample demonstrates how to inject additional AI context into a ChatClientAgent using multiple custom AIContextProvider components that are attached to the agent.| +|[Using compaction pipeline with an agent](./Agent_Step18_CompactionPipeline/)|This sample demonstrates how to use a compaction pipeline to efficiently limit the size of the conversation history for an agent.| ## Running the samples from the console From 4d6e1ffd4749047bc008f7d4d644f578ee3093f5 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Thu, 5 Mar 2026 09:17:05 -0800 Subject: [PATCH 17/22] Experimental --- .../Microsoft.Agents.AI/ChatClient/ChatClientAgentOptions.cs | 3 +++ .../Microsoft.Agents.AI/Compaction/CompactingChatClient.cs | 3 +++ .../src/Microsoft.Agents.AI/Compaction/CompactionStrategy.cs | 3 +++ .../src/Microsoft.Agents.AI/Compaction/CompactionTrigger.cs | 4 ++++ .../src/Microsoft.Agents.AI/Compaction/CompactionTriggers.cs | 3 +++ dotnet/src/Microsoft.Agents.AI/Compaction/MessageGroup.cs | 3 +++ dotnet/src/Microsoft.Agents.AI/Compaction/MessageGroupKind.cs | 4 ++++ dotnet/src/Microsoft.Agents.AI/Compaction/MessageIndex.cs | 3 +++ .../Compaction/PipelineCompactionStrategy.cs | 3 +++ .../Compaction/SlidingWindowCompactionStrategy.cs | 3 +++ .../Compaction/SummarizationCompactionStrategy.cs | 3 +++ .../Compaction/ToolResultCompactionStrategy.cs | 3 +++ .../Compaction/TruncationCompactionStrategy.cs | 3 +++ 13 files changed, 41 insertions(+) diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentOptions.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentOptions.cs index 20dd3647d7..affd21398f 100644 --- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentOptions.cs @@ -1,8 +1,10 @@ // Copyright (c) Microsoft. All rights reserved. using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using Microsoft.Agents.AI.Compaction; using Microsoft.Extensions.AI; +using Microsoft.Shared.DiagnosticIds; namespace Microsoft.Agents.AI; @@ -60,6 +62,7 @@ public sealed class ChatClientAgentOptions /// before applying compaction logic. See for details. /// /// + [Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] public CompactionStrategy? CompactionStrategy { get; set; } /// diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactingChatClient.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactingChatClient.cs index d2a341292a..29ac2e0994 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactingChatClient.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactingChatClient.cs @@ -3,11 +3,13 @@ using System; using System.Collections.Generic; using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.AI; +using Microsoft.Shared.DiagnosticIds; using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.AI.Compaction; @@ -27,6 +29,7 @@ namespace Microsoft.Agents.AI.Compaction; /// before applying compaction logic. Only included messages are forwarded to the inner client. /// /// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] internal sealed class CompactingChatClient : DelegatingChatClient { private readonly CompactionStrategy _compactionStrategy; diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionStrategy.cs index dc8ece399c..489fb44e0a 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionStrategy.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionStrategy.cs @@ -1,8 +1,10 @@ // Copyright (c) Microsoft. All rights reserved. using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.Threading; using System.Threading.Tasks; +using Microsoft.Shared.DiagnosticIds; using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.AI.Compaction; @@ -41,6 +43,7 @@ namespace Microsoft.Agents.AI.Compaction; /// via . /// /// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] public abstract class CompactionStrategy { /// diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionTrigger.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionTrigger.cs index fb58bc1a2c..4803c6a6fa 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionTrigger.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionTrigger.cs @@ -1,5 +1,8 @@ // Copyright (c) Microsoft. All rights reserved. +using System.Diagnostics.CodeAnalysis; +using Microsoft.Shared.DiagnosticIds; + namespace Microsoft.Agents.AI.Compaction; /// @@ -7,4 +10,5 @@ namespace Microsoft.Agents.AI.Compaction; /// /// The current message index with group, token, message, and turn metrics. /// if compaction should proceed; to skip. +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] public delegate bool CompactionTrigger(MessageIndex index); diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionTriggers.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionTriggers.cs index d2064c4449..53d3782a5f 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionTriggers.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionTriggers.cs @@ -1,6 +1,8 @@ // Copyright (c) Microsoft. All rights reserved. +using System.Diagnostics.CodeAnalysis; using System.Linq; +using Microsoft.Shared.DiagnosticIds; namespace Microsoft.Agents.AI.Compaction; @@ -12,6 +14,7 @@ namespace Microsoft.Agents.AI.Compaction; /// Combine triggers with or for compound conditions, /// or write a custom lambda for full flexibility. /// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] public static class CompactionTriggers { /// diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/MessageGroup.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/MessageGroup.cs index f8d41a41fb..df1098c7da 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/MessageGroup.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/MessageGroup.cs @@ -1,8 +1,10 @@ // 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; @@ -26,6 +28,7 @@ namespace Microsoft.Agents.AI.Compaction; /// These values are computed by and passed into the constructor. /// /// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] public sealed class MessageGroup { /// diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/MessageGroupKind.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/MessageGroupKind.cs index 68d7d1fc09..74018d067b 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/MessageGroupKind.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/MessageGroupKind.cs @@ -1,5 +1,8 @@ // Copyright (c) Microsoft. All rights reserved. +using System.Diagnostics.CodeAnalysis; +using Microsoft.Shared.DiagnosticIds; + namespace Microsoft.Agents.AI.Compaction; /// @@ -10,6 +13,7 @@ namespace Microsoft.Agents.AI.Compaction; /// during compaction operations. For example, an assistant message containing tool calls /// and its corresponding tool result messages form an atomic group. /// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] public enum MessageGroupKind { /// diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/MessageIndex.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/MessageIndex.cs index dff5b4764f..3de2efa390 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/MessageIndex.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/MessageIndex.cs @@ -2,10 +2,12 @@ using System.Collections.Generic; using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Text; using Microsoft.Extensions.AI; using Microsoft.ML.Tokenizers; +using Microsoft.Shared.DiagnosticIds; namespace Microsoft.Agents.AI.Compaction; @@ -34,6 +36,7 @@ namespace Microsoft.Agents.AI.Compaction; /// appending only new messages without reprocessing the entire history. /// /// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] public sealed class MessageIndex { private int _currentTurn; diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/PipelineCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/PipelineCompactionStrategy.cs index d48b3e7bad..fa23239156 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/PipelineCompactionStrategy.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/PipelineCompactionStrategy.cs @@ -1,8 +1,10 @@ // Copyright (c) Microsoft. All rights reserved. using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Threading; using System.Threading.Tasks; +using Microsoft.Shared.DiagnosticIds; using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.AI.Compaction; @@ -22,6 +24,7 @@ namespace Microsoft.Agents.AI.Compaction; /// evaluates its own trigger independently. /// /// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] public sealed class PipelineCompactionStrategy : CompactionStrategy { /// diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/SlidingWindowCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/SlidingWindowCompactionStrategy.cs index 6a7f2e5c73..11effccaa4 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/SlidingWindowCompactionStrategy.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/SlidingWindowCompactionStrategy.cs @@ -2,8 +2,10 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Threading; using System.Threading.Tasks; +using Microsoft.Shared.DiagnosticIds; namespace Microsoft.Agents.AI.Compaction; @@ -26,6 +28,7 @@ namespace Microsoft.Agents.AI.Compaction; /// length, since it operates on logical turn boundaries rather than estimated token counts. /// /// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] public sealed class SlidingWindowCompactionStrategy : CompactionStrategy { /// diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/SummarizationCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/SummarizationCompactionStrategy.cs index 54c7ab4cdd..d87a9e63a7 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/SummarizationCompactionStrategy.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/SummarizationCompactionStrategy.cs @@ -1,11 +1,13 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Text; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.AI; +using Microsoft.Shared.DiagnosticIds; using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.AI.Compaction; @@ -31,6 +33,7 @@ namespace Microsoft.Agents.AI.Compaction; /// Use for common trigger conditions such as token thresholds. /// /// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] public sealed class SummarizationCompactionStrategy : CompactionStrategy { /// diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/ToolResultCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/ToolResultCompactionStrategy.cs index a54c096cd2..91737394ab 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/ToolResultCompactionStrategy.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/ToolResultCompactionStrategy.cs @@ -2,9 +2,11 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.AI; +using Microsoft.Shared.DiagnosticIds; namespace Microsoft.Agents.AI.Compaction; @@ -31,6 +33,7 @@ namespace Microsoft.Agents.AI.Compaction; /// is used. /// /// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] public sealed class ToolResultCompactionStrategy : CompactionStrategy { /// diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/TruncationCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/TruncationCompactionStrategy.cs index 3c7d8890f2..46960561fc 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/TruncationCompactionStrategy.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/TruncationCompactionStrategy.cs @@ -1,7 +1,9 @@ // Copyright (c) Microsoft. All rights reserved. +using System.Diagnostics.CodeAnalysis; using System.Threading; using System.Threading.Tasks; +using Microsoft.Shared.DiagnosticIds; namespace Microsoft.Agents.AI.Compaction; @@ -24,6 +26,7 @@ namespace Microsoft.Agents.AI.Compaction; /// Use for common trigger conditions such as token or group thresholds. /// /// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] public sealed class TruncationCompactionStrategy : CompactionStrategy { /// From 2f443a1976cb11cdec3111f0ef6d6bb634698062 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Thu, 5 Mar 2026 09:53:55 -0800 Subject: [PATCH 18/22] Format --- .../Compaction/CompactingChatClientTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactingChatClientTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactingChatClientTests.cs index c165d8c1e6..ce3bf167e5 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactingChatClientTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactingChatClientTests.cs @@ -26,7 +26,7 @@ public void Dispose() } [Fact] - public void ConstructorThrowsOnNullStrategyAsync() + public void ConstructorThrowsOnNullStrategy() { Mock mockInner = new(); Assert.Throws(() => new CompactingChatClient(mockInner.Object, null!)); From 209d0e3fe104f7bae3406d0d607550303faee3f7 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Thu, 5 Mar 2026 10:10:01 -0800 Subject: [PATCH 19/22] Formatting --- .../Compaction/CompactionStrategyTests.cs | 8 +++---- .../Compaction/MessageIndexTests.cs | 23 ++++++++++--------- .../ToolResultCompactionStrategyTests.cs | 6 ++--- 3 files changed, 19 insertions(+), 18 deletions(-) diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionStrategyTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionStrategyTests.cs index a89ac0702e..8aa1f30637 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionStrategyTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionStrategyTests.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System; using System.Threading; @@ -109,13 +109,13 @@ public async Task CompactAsyncCustomTargetIsPassedToStrategyAsync() { // Arrange — custom target that always signals stop bool targetCalled = false; - CompactionTrigger customTarget = _ => + bool CustomTarget(MessageIndex _) { targetCalled = true; return true; - }; + } - TestStrategy strategy = new(_ => true, customTarget, _ => + TestStrategy strategy = new(_ => true, CustomTarget, _ => { // Access the target from within the strategy return true; diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/MessageIndexTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/MessageIndexTests.cs index b17b0d1b95..395e6ff2bf 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/MessageIndexTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/MessageIndexTests.cs @@ -1,9 +1,10 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System.Buffers; using System.Collections.Generic; using Microsoft.Agents.AI.Compaction; using Microsoft.Extensions.AI; +using Microsoft.ML.Tokenizers; namespace Microsoft.Agents.AI.UnitTests.Compaction; @@ -865,35 +866,35 @@ public void ComputeTokenCountHandlesNullAndNonNullText() /// /// A simple tokenizer that counts whitespace-separated words as tokens. /// - private sealed class SimpleWordTokenizer : Microsoft.ML.Tokenizers.Tokenizer + private sealed class SimpleWordTokenizer : Tokenizer { - public override Microsoft.ML.Tokenizers.PreTokenizer? PreTokenizer => null; - public override Microsoft.ML.Tokenizers.Normalizer? Normalizer => null; + public override PreTokenizer? PreTokenizer => null; + public override Normalizer? Normalizer => null; - protected override Microsoft.ML.Tokenizers.EncodeResults EncodeToTokens(string? text, System.ReadOnlySpan textSpan, Microsoft.ML.Tokenizers.EncodeSettings settings) + protected override EncodeResults EncodeToTokens(string? text, System.ReadOnlySpan textSpan, EncodeSettings settings) { // Simple word-based encoding string input = text ?? textSpan.ToString(); if (string.IsNullOrWhiteSpace(input)) { - return new Microsoft.ML.Tokenizers.EncodeResults + return new EncodeResults { - Tokens = System.Array.Empty(), + Tokens = System.Array.Empty(), CharsConsumed = 0, NormalizedText = null, }; } string[] words = input.Split(' '); - List tokens = []; + List tokens = []; int offset = 0; for (int i = 0; i < words.Length; i++) { - tokens.Add(new Microsoft.ML.Tokenizers.EncodedToken(i, words[i], new System.Range(offset, offset + words[i].Length))); + tokens.Add(new EncodedToken(i, words[i], new System.Range(offset, offset + words[i].Length))); offset += words[i].Length + 1; } - return new Microsoft.ML.Tokenizers.EncodeResults + return new EncodeResults { Tokens = tokens, CharsConsumed = input.Length, @@ -901,7 +902,7 @@ private sealed class SimpleWordTokenizer : Microsoft.ML.Tokenizers.Tokenizer }; } - public override OperationStatus Decode(System.Collections.Generic.IEnumerable ids, System.Span destination, out int idsConsumed, out int charsWritten) + public override OperationStatus Decode(IEnumerable ids, System.Span destination, out int idsConsumed, out int charsWritten) { idsConsumed = 0; charsWritten = 0; diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/ToolResultCompactionStrategyTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/ToolResultCompactionStrategyTests.cs index 2b925aef76..3d4a34ac54 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/ToolResultCompactionStrategyTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/ToolResultCompactionStrategyTests.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System.Collections.Generic; using System.Threading.Tasks; @@ -197,12 +197,12 @@ public async Task CompactAsyncTargetStopsCollapsingEarlyAsync() { // Arrange — 2 tool groups, target met after first collapse int collapseCount = 0; - CompactionTrigger targetAfterOne = _ => ++collapseCount >= 1; + bool TargetAfterOne(MessageIndex _) => ++collapseCount >= 1; ToolResultCompactionStrategy strategy = new( trigger: _ => true, minimumPreserved: 1, - target: targetAfterOne); + target: TargetAfterOne); MessageIndex index = MessageIndex.Create( [ From 84aa3922b152bf62403c6500c3f0c1357e5b2301 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Thu, 5 Mar 2026 10:26:03 -0800 Subject: [PATCH 20/22] Encoding --- .../Compaction/CompactionTriggersTests.cs | 2 +- .../Compaction/PipelineCompactionStrategyTests.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionTriggersTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionTriggersTests.cs index fe4ae320a9..be3a874459 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionTriggersTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionTriggersTests.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using Microsoft.Agents.AI.Compaction; using Microsoft.Extensions.AI; diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/PipelineCompactionStrategyTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/PipelineCompactionStrategyTests.cs index d14b019552..0bb7b022dc 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/PipelineCompactionStrategyTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/PipelineCompactionStrategyTests.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System; using System.Collections.Generic; From 9c1165f077e28999f4fd6ddf6d71527f4b0525e0 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Thu, 5 Mar 2026 11:28:42 -0800 Subject: [PATCH 21/22] Support IChatReducer --- .../ChatReducerCompactionStrategy.cs | 91 +++++++ .../ChatReducerCompactionStrategyTests.cs | 255 ++++++++++++++++++ 2 files changed, 346 insertions(+) create mode 100644 dotnet/src/Microsoft.Agents.AI/Compaction/ChatReducerCompactionStrategy.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/ChatReducerCompactionStrategyTests.cs diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/ChatReducerCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/ChatReducerCompactionStrategy.cs new file mode 100644 index 0000000000..de1b7481d0 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/ChatReducerCompactionStrategy.cs @@ -0,0 +1,91 @@ +// 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.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Compaction; + +/// +/// A compaction strategy that delegates to an to reduce the conversation's +/// included messages. +/// +/// +/// +/// This strategy bridges the abstraction from Microsoft.Extensions.AI +/// into the compaction pipeline. It collects the currently included messages from the +/// , passes them to the reducer, and rebuilds the index from the +/// reduced message list when the reducer produces fewer messages. +/// +/// +/// The controls when reduction is attempted. +/// Use for common trigger conditions such as token or message thresholds. +/// +/// +/// Use this strategy when you have an existing implementation +/// (such as MessageCountingChatReducer) and want to apply it as part of a +/// pipeline or as an in-run compaction strategy. +/// +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public sealed class ChatReducerCompactionStrategy : CompactionStrategy +{ + /// + /// Initializes a new instance of the class. + /// + /// + /// The that performs the message reduction. + /// + /// + /// The that controls when compaction proceeds. + /// + /// + /// An optional target condition that controls when compaction stops. When , + /// defaults to the inverse of the — compaction stops as soon as the trigger would no longer fire. + /// Note that the performs reduction in a single call, so the target is + /// not evaluated incrementally; it is available for composition with other strategies via + /// . + /// + public ChatReducerCompactionStrategy(IChatReducer chatReducer, CompactionTrigger trigger, CompactionTrigger? target = null) + : base(trigger, target) + { + this.ChatReducer = Throw.IfNull(chatReducer); + } + + /// + /// Gets the chat reducer used to reduce messages. + /// + public IChatReducer ChatReducer { get; } + + /// + protected override async Task ApplyCompactionAsync(MessageIndex index, CancellationToken cancellationToken) + { + List includedMessages = [.. index.GetIncludedMessages()]; + if (includedMessages.Count == 0) + { + return false; + } + + IEnumerable reduced = await this.ChatReducer.ReduceAsync(includedMessages, cancellationToken).ConfigureAwait(false); + IList reducedMessages = reduced as IList ?? [.. reduced]; + + if (reducedMessages.Count >= includedMessages.Count) + { + return false; + } + + // Rebuild the index from the reduced messages + MessageIndex rebuilt = MessageIndex.Create(reducedMessages, index.Tokenizer); + index.Groups.Clear(); + foreach (MessageGroup group in rebuilt.Groups) + { + index.Groups.Add(group); + } + + return true; + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/ChatReducerCompactionStrategyTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/ChatReducerCompactionStrategyTests.cs new file mode 100644 index 0000000000..6d4437d97a --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/ChatReducerCompactionStrategyTests.cs @@ -0,0 +1,255 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +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 ChatReducerCompactionStrategyTests +{ + [Fact] + public void ConstructorNullReducerThrows() + { + // Act & Assert + Assert.Throws(() => new ChatReducerCompactionStrategy(null!, CompactionTriggers.Always)); + } + + [Fact] + public async Task CompactAsyncTriggerNotMetReturnsFalseAsync() + { + // Arrange — trigger never fires + TestChatReducer reducer = new(messages => messages.Take(1)); + ChatReducerCompactionStrategy strategy = new(reducer, CompactionTriggers.Never); + MessageIndex index = MessageIndex.Create( + [ + new ChatMessage(ChatRole.User, "Hello"), + new ChatMessage(ChatRole.Assistant, "Hi!"), + ]); + + // Act + bool result = await strategy.CompactAsync(index); + + // Assert + Assert.False(result); + Assert.Equal(0, reducer.CallCount); + Assert.Equal(2, index.IncludedGroupCount); + } + + [Fact] + public async Task CompactAsyncReducerReturnsFewerMessagesRebuildsIndexAsync() + { + // Arrange — reducer keeps only the last message + TestChatReducer reducer = new(messages => messages.Skip(messages.Count() - 1)); + ChatReducerCompactionStrategy strategy = new(reducer, CompactionTriggers.Always); + MessageIndex index = MessageIndex.Create( + [ + new ChatMessage(ChatRole.User, "First"), + new ChatMessage(ChatRole.Assistant, "Response 1"), + new ChatMessage(ChatRole.User, "Second"), + ]); + + // Act + bool result = await strategy.CompactAsync(index); + + // Assert + Assert.True(result); + Assert.Equal(1, reducer.CallCount); + Assert.Equal(1, index.IncludedGroupCount); + Assert.Equal("Second", index.Groups[0].Messages[0].Text); + } + + [Fact] + public async Task CompactAsyncReducerReturnsSameCountReturnsFalseAsync() + { + // Arrange — reducer returns all messages (no reduction) + TestChatReducer reducer = new(messages => messages); + ChatReducerCompactionStrategy strategy = new(reducer, CompactionTriggers.Always); + MessageIndex index = MessageIndex.Create( + [ + new ChatMessage(ChatRole.User, "Hello"), + new ChatMessage(ChatRole.Assistant, "Hi!"), + ]); + + // Act + bool result = await strategy.CompactAsync(index); + + // Assert + Assert.False(result); + Assert.Equal(1, reducer.CallCount); + Assert.Equal(2, index.IncludedGroupCount); + } + + [Fact] + public async Task CompactAsyncEmptyIndexReturnsFalseAsync() + { + // Arrange — no included messages + TestChatReducer reducer = new(messages => messages); + ChatReducerCompactionStrategy strategy = new(reducer, CompactionTriggers.Always); + MessageIndex index = MessageIndex.Create([]); + + // Act + bool result = await strategy.CompactAsync(index); + + // Assert + Assert.False(result); + Assert.Equal(0, reducer.CallCount); + } + + [Fact] + public async Task CompactAsyncPreservesSystemMessagesWhenReducerKeepsThemAsync() + { + // Arrange — reducer keeps system + last user message + TestChatReducer reducer = new(messages => + { + var nonSystem = messages.Where(m => m.Role != ChatRole.System).ToList(); + return messages.Where(m => m.Role == ChatRole.System) + .Concat(nonSystem.Skip(nonSystem.Count - 1)); + }); + + ChatReducerCompactionStrategy strategy = new(reducer, CompactionTriggers.Always); + MessageIndex index = MessageIndex.Create( + [ + new ChatMessage(ChatRole.System, "You are helpful."), + new ChatMessage(ChatRole.User, "First"), + new ChatMessage(ChatRole.Assistant, "Response 1"), + new ChatMessage(ChatRole.User, "Second"), + ]); + + // Act + bool result = await strategy.CompactAsync(index); + + // Assert + Assert.True(result); + Assert.Equal(2, index.IncludedGroupCount); + Assert.Equal(MessageGroupKind.System, index.Groups[0].Kind); + Assert.Equal("You are helpful.", index.Groups[0].Messages[0].Text); + Assert.Equal(MessageGroupKind.User, index.Groups[1].Kind); + Assert.Equal("Second", index.Groups[1].Messages[0].Text); + } + + [Fact] + public async Task CompactAsyncRebuildsToolCallGroupsCorrectlyAsync() + { + // Arrange — reducer keeps last 3 messages (assistant tool call + tool result + user) + TestChatReducer reducer = new(messages => messages.Skip(messages.Count() - 3)); + + ChatMessage assistantToolCall = new(ChatRole.Assistant, [new FunctionCallContent("call1", "get_weather")]); + ChatMessage toolResult = new(ChatRole.Tool, "Sunny"); + + ChatReducerCompactionStrategy strategy = new(reducer, CompactionTriggers.Always); + MessageIndex index = MessageIndex.Create( + [ + new ChatMessage(ChatRole.User, "Old question"), + new ChatMessage(ChatRole.Assistant, "Old answer"), + assistantToolCall, + toolResult, + new ChatMessage(ChatRole.User, "New question"), + ]); + + // Act + bool result = await strategy.CompactAsync(index); + + // Assert + Assert.True(result); + // Should have 2 groups: ToolCall group (assistant + tool result) + User group + Assert.Equal(2, index.IncludedGroupCount); + Assert.Equal(MessageGroupKind.ToolCall, index.Groups[0].Kind); + Assert.Equal(2, index.Groups[0].Messages.Count); + Assert.Equal(MessageGroupKind.User, index.Groups[1].Kind); + } + + [Fact] + public async Task CompactAsyncSkipsAlreadyExcludedGroupsAsync() + { + // Arrange — one group is pre-excluded, reducer keeps last message + TestChatReducer reducer = new(messages => messages.Skip(messages.Count() - 1)); + ChatReducerCompactionStrategy strategy = new(reducer, CompactionTriggers.Always); + MessageIndex index = MessageIndex.Create( + [ + new ChatMessage(ChatRole.User, "Excluded"), + new ChatMessage(ChatRole.User, "Included 1"), + new ChatMessage(ChatRole.User, "Included 2"), + ]); + index.Groups[0].IsExcluded = true; + + // Act + bool result = await strategy.CompactAsync(index); + + // Assert — reducer only saw 2 included messages, kept 1 + Assert.True(result); + Assert.Equal(1, index.IncludedGroupCount); + Assert.Equal("Included 2", index.Groups[0].Messages[0].Text); + } + + [Fact] + public async Task CompactAsyncExposesReducerPropertyAsync() + { + // Arrange + TestChatReducer reducer = new(messages => messages); + ChatReducerCompactionStrategy strategy = new(reducer, CompactionTriggers.Always); + + // Assert + Assert.Same(reducer, strategy.ChatReducer); + await Task.CompletedTask; + } + + [Fact] + public async Task CompactAsyncPassesCancellationTokenToReducerAsync() + { + // Arrange + using CancellationTokenSource cts = new(); + CancellationToken capturedToken = default; + TestChatReducer reducer = new((messages, ct) => + { + capturedToken = ct; + return Task.FromResult>(messages.Skip(messages.Count() - 1).ToList()); + }); + + ChatReducerCompactionStrategy strategy = new(reducer, CompactionTriggers.Always); + MessageIndex index = MessageIndex.Create( + [ + new ChatMessage(ChatRole.User, "First"), + new ChatMessage(ChatRole.User, "Second"), + ]); + + // Act + await strategy.CompactAsync(index, cts.Token); + + // Assert + Assert.Equal(cts.Token, capturedToken); + } + + /// + /// A test implementation of that applies a configurable reduction function. + /// + private sealed class TestChatReducer : IChatReducer + { + private readonly Func, CancellationToken, Task>> _reduceFunc; + + public TestChatReducer(Func, IEnumerable> reduceFunc) + { + this._reduceFunc = (messages, _) => Task.FromResult(reduceFunc(messages)); + } + + public TestChatReducer(Func, CancellationToken, Task>> reduceFunc) + { + this._reduceFunc = reduceFunc; + } + + public int CallCount { get; private set; } + + public async Task> ReduceAsync(IEnumerable messages, CancellationToken cancellationToken = default) + { + this.CallCount++; + return await this._reduceFunc(messages, cancellationToken).ConfigureAwait(false); + } + } +} From 7afed95614e0f7f466967bf991e28c8660d70151 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Thu, 5 Mar 2026 16:32:29 -0800 Subject: [PATCH 22/22] Sample output formatting --- .../Agent_Step18_CompactionPipeline/Program.cs | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/Program.cs b/dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/Program.cs index 0118ac1d60..472dd4cb0a 100644 --- a/dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/Program.cs +++ b/dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/Program.cs @@ -79,7 +79,9 @@ void PrintChatHistory() { if (session.TryGetInMemoryChatHistory(out var history)) { - Console.WriteLine($" [Chat history: {history.Count} messages]\n"); + Console.ForegroundColor = ConsoleColor.Cyan; + Console.WriteLine($"\n[Messages: x{history.Count}]\n"); + Console.ResetColor(); } } @@ -97,8 +99,14 @@ void PrintChatHistory() foreach (string prompt in prompts) { - Console.WriteLine($"User: {prompt}"); - Console.WriteLine($"Agent: {await agent.RunAsync(prompt, session)}"); + Console.ForegroundColor = ConsoleColor.Cyan; + Console.Write("\n[User] "); + Console.ResetColor(); + Console.WriteLine(prompt); + Console.ForegroundColor = ConsoleColor.Cyan; + Console.Write("\n[Agent] "); + Console.ResetColor(); + Console.WriteLine(await agent.RunAsync(prompt, session)); PrintChatHistory(); }