From 23cf75be3cb863a295636e82de2323fa96931e50 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Wed, 4 Mar 2026 18:12:23 -0800 Subject: [PATCH 01/80] 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/80] 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/80] 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/80] 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/80] 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/80] 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/80] 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/80] 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/80] 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/80] 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/80] 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/80] 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/80] 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/80] 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/80] 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/80] 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/80] 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/80] 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/80] 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/80] 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/80] 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/80] 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(); } From 159899161a72df7e315e38a78b59e5e42be019c6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Mar 2026 18:46:02 +0000 Subject: [PATCH 23/80] Initial plan From dc2bb4d446d694f6aaf5c8aab00614c8300f47a0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Mar 2026 19:03:20 +0000 Subject: [PATCH 24/80] Replace CompactingChatClient with MessageCompactionContextProvider Co-authored-by: crickman <66376200+crickman@users.noreply.github.com> --- .../Program.cs | 9 +- .../ChatClient/ChatClientAgentOptions.cs | 21 - .../ChatClient/ChatClientExtensions.cs | 8 - .../Compaction/CompactingChatClient.cs | 141 ------- .../MessageCompactionContextProvider.cs | 127 ++++++ .../Compaction/CompactingChatClientTests.cs | 399 ------------------ .../MessageCompactionContextProviderTests.cs | 259 ++++++++++++ 7 files changed, 391 insertions(+), 573 deletions(-) delete mode 100644 dotnet/src/Microsoft.Agents.AI/Compaction/CompactingChatClient.cs create mode 100644 dotnet/src/Microsoft.Agents.AI/Compaction/MessageCompactionContextProvider.cs delete mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactingChatClientTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/MessageCompactionContextProviderTests.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 472dd4cb0a..1039685bd7 100644 --- a/dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/Program.cs +++ b/dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/Program.cs @@ -1,7 +1,8 @@ // 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: +// This sample demonstrates how to use a MessageCompactionContextProvider with a compaction pipeline +// as an AIContextProvider for an agent's in-run context management. 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 @@ -52,7 +53,7 @@ static string LookupPrice([Description("The product name to look up.")] string p // 4. Emergency: drop oldest groups until under the token budget new TruncationCompactionStrategy(CompactionTriggers.TokensExceed(0x8000))); -// Create the agent with an in-memory chat history provider whose reducer is the compaction pipeline. +// Create the agent with a MessageCompactionContextProvider that uses the compaction pipeline. AIAgent agent = agentChatClient.AsAIAgent( new ChatClientAgentOptions @@ -69,7 +70,7 @@ many words as possible without sounding ridiculous. """, Tools = [AIFunctionFactory.Create(LookupPrice)], }, - CompactionStrategy = compactionPipeline, + AIContextProviders = [new MessageCompactionContextProvider(compactionPipeline)], }); AgentSession session = await agent.CreateSessionAsync(); diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentOptions.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentOptions.cs index affd21398f..38cad40bbe 100644 --- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentOptions.cs @@ -1,10 +1,7 @@ // 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; @@ -48,23 +45,6 @@ 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. - /// - /// - [Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] - public CompactionStrategy? CompactionStrategy { get; set; } - /// /// Gets or sets a value indicating whether to use the provided instance as is, /// without applying any default decorators. @@ -121,7 +101,6 @@ 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 a8d57ec3d0..8290c39974 100644 --- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientExtensions.cs @@ -4,7 +4,6 @@ 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; @@ -54,13 +53,6 @@ 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) => diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactingChatClient.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactingChatClient.cs deleted file mode 100644 index 29ac2e0994..0000000000 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactingChatClient.cs +++ /dev/null @@ -1,141 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -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; - -/// -/// 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. -/// -/// -[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] -internal sealed class CompactingChatClient : DelegatingChatClient -{ - private readonly CompactionStrategy _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, CompactionStrategy 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 (ChatResponseUpdate 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(CompactionStrategy)) ? - 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 - 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) - { - 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/MessageCompactionContextProvider.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/MessageCompactionContextProvider.cs new file mode 100644 index 0000000000..a506af9e88 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/MessageCompactionContextProvider.cs @@ -0,0 +1,127 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Compaction; + +/// +/// A that applies a to compact +/// the message list before each agent invocation. +/// +/// +/// +/// This provider performs in-run compaction by organizing messages into atomic groups (preserving +/// tool-call/result pairings) before applying compaction logic. Only included messages are forwarded +/// to the agent's underlying chat client. +/// +/// +/// The can be added to an agent's context provider pipeline +/// via or via UseAIContextProviders +/// on a or . +/// +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public sealed class MessageCompactionContextProvider : MessageAIContextProvider +{ + private readonly CompactionStrategy _compactionStrategy; + private readonly ProviderSessionState _sessionState; + private IReadOnlyList? _stateKeys; + + /// + /// Initializes a new instance of the class. + /// + /// The compaction strategy to apply before each invocation. + /// + /// An optional key used to store the provider state in the . + /// Defaults to "MessageCompactionContextProvider". + /// + /// is . + public MessageCompactionContextProvider(CompactionStrategy compactionStrategy, string? stateKey = null) + { + this._compactionStrategy = Throw.IfNull(compactionStrategy); + this._sessionState = new ProviderSessionState( + _ => new State(), + stateKey ?? nameof(MessageCompactionContextProvider), + AgentJsonUtilities.DefaultOptions); + } + + /// + public override IReadOnlyList StateKeys => this._stateKeys ??= [this._sessionState.StateKey]; + + /// + /// Applies the compaction strategy to the accumulated message list before forwarding it to the agent. + /// + /// Contains the request context including all accumulated messages. + /// The to monitor for cancellation requests. + /// + /// A task that represents the asynchronous operation. The task result contains an + /// with the compacted message list. If no compaction was needed, the original context is returned unchanged. + /// + protected override async ValueTask InvokingCoreAsync(AIContextProvider.InvokingContext context, CancellationToken cancellationToken = default) + { + var session = context.Session; + var allMessages = context.AIContext.Messages; + + if (session is null || allMessages is null) + { + // No session available or no messages — pass through unchanged. + return context.AIContext; + } + + List messageList = allMessages as List ?? [.. allMessages]; + + State state = this._sessionState.GetOrInitializeState(session); + + MessageIndex messageIndex; + if (state.MessageIndex.Count > 0) + { + // Update existing index with any new messages appended since the last call. + messageIndex = new(state.MessageIndex); + messageIndex.Update(messageList); + } + else + { + // First pass — initialize the message index from scratch. + messageIndex = MessageIndex.Create(messageList); + } + + // 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) + { + state.MessageIndex = [.. messageIndex.Groups]; + } + + return new AIContext + { + Instructions = context.AIContext.Instructions, + Messages = wasCompacted ? messageIndex.GetIncludedMessages() : (IEnumerable)messageList, + Tools = context.AIContext.Tools + }; + } + + /// + /// Represents the persisted state of a stored in the . + /// + public sealed class State + { + /// + /// Gets or sets the message index groups used for incremental compaction updates. + /// + [JsonPropertyName("messages")] + public List MessageIndex { get; set; } = []; + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactingChatClientTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactingChatClientTests.cs deleted file mode 100644 index ce3bf167e5..0000000000 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactingChatClientTests.cs +++ /dev/null @@ -1,399 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Collections.Generic; -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 ConstructorThrowsOnNullStrategy() - { - 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(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(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/MessageCompactionContextProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/MessageCompactionContextProviderTests.cs new file mode 100644 index 0000000000..c402b70874 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/MessageCompactionContextProviderTests.cs @@ -0,0 +1,259 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Reflection; +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 MessageCompactionContextProviderTests +{ + [Fact] + public void ConstructorThrowsOnNullStrategy() + { + Assert.Throws(() => new MessageCompactionContextProvider(null!)); + } + + [Fact] + public void StateKeysReturnsExpectedKey() + { + // Arrange + TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(100000)); + MessageCompactionContextProvider provider = new(strategy); + + // Act & Assert — default state key is the class name + Assert.Single(provider.StateKeys); + Assert.Equal(nameof(MessageCompactionContextProvider), provider.StateKeys[0]); + } + + [Fact] + public void StateKeysReturnsCustomKeyWhenProvided() + { + // Arrange + TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(100000)); + MessageCompactionContextProvider provider = new(strategy, stateKey: "my-custom-key"); + + // Act & Assert + Assert.Single(provider.StateKeys); + Assert.Equal("my-custom-key", provider.StateKeys[0]); + } + + [Fact] + public async Task InvokingAsyncNoSessionPassesThroughAsync() + { + // Arrange — no session → passthrough + TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(100000)); + MessageCompactionContextProvider provider = new(strategy); + + Mock mockAgent = new() { CallBase = true }; + List messages = + [ + new ChatMessage(ChatRole.User, "Hello"), + ]; + + AIContextProvider.InvokingContext context = new( + mockAgent.Object, + session: null, + new AIContext { Messages = messages }); + + // Act + AIContext result = await provider.InvokingAsync(context); + + // Assert — original context returned unchanged + Assert.Same(messages, result.Messages); + } + + [Fact] + public async Task InvokingAsyncNullMessagesPassesThroughAsync() + { + // Arrange — messages is null → passthrough + TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(100000)); + MessageCompactionContextProvider provider = new(strategy); + + Mock mockAgent = new() { CallBase = true }; + TestAgentSession session = new(); + AIContextProvider.InvokingContext context = new( + mockAgent.Object, + session, + new AIContext { Messages = null }); + + // Act + AIContext result = await provider.InvokingAsync(context); + + // Assert — original context returned unchanged + Assert.Null(result.Messages); + } + + [Fact] + public async Task InvokingAsyncAppliesCompactionWhenTriggeredAsync() + { + // Arrange — strategy that always triggers and keeps only 1 group + TruncationCompactionStrategy strategy = new(_ => true, minimumPreserved: 1); + MessageCompactionContextProvider provider = new(strategy); + + Mock mockAgent = new() { CallBase = true }; + TestAgentSession session = new(); + List messages = + [ + new ChatMessage(ChatRole.User, "Q1"), + new ChatMessage(ChatRole.Assistant, "A1"), + new ChatMessage(ChatRole.User, "Q2"), + ]; + + AIContextProvider.InvokingContext context = new( + mockAgent.Object, + session, + new AIContext { Messages = messages }); + + // Act + AIContext result = await provider.InvokingAsync(context); + + // Assert — compaction should have reduced the message count + Assert.NotNull(result.Messages); + List resultList = [.. result.Messages!]; + Assert.True(resultList.Count < messages.Count); + } + + [Fact] + public async Task InvokingAsyncNoCompactionNeededReturnsOriginalMessagesAsync() + { + // Arrange — trigger never fires → no compaction + TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(100000)); + MessageCompactionContextProvider provider = new(strategy); + + Mock mockAgent = new() { CallBase = true }; + TestAgentSession session = new(); + List messages = + [ + new ChatMessage(ChatRole.User, "Hello"), + ]; + + AIContextProvider.InvokingContext context = new( + mockAgent.Object, + session, + new AIContext { Messages = messages }); + + // Act + AIContext result = await provider.InvokingAsync(context); + + // Assert — original messages passed through + Assert.NotNull(result.Messages); + List resultList = [.. result.Messages!]; + Assert.Single(resultList); + Assert.Equal("Hello", resultList[0].Text); + } + + [Fact] + public async Task InvokingAsyncPreservesInstructionsAndToolsAsync() + { + // Arrange + TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(100000)); + MessageCompactionContextProvider provider = new(strategy); + + Mock mockAgent = new() { CallBase = true }; + TestAgentSession session = new(); + List messages = [new ChatMessage(ChatRole.User, "Hello")]; + AITool[] tools = [AIFunctionFactory.Create(() => "tool", "MyTool")]; + + AIContextProvider.InvokingContext context = new( + mockAgent.Object, + session, + new AIContext + { + Instructions = "Be helpful", + Messages = messages, + Tools = tools + }); + + // Act + AIContext result = await provider.InvokingAsync(context); + + // Assert — instructions and tools are preserved + Assert.Equal("Be helpful", result.Instructions); + Assert.Same(tools, result.Tools); + } + + [Fact] + public async Task InvokingAsyncWithExistingIndexUpdatesAsync() + { + // Arrange — call twice to exercise the "existing index" path + TruncationCompactionStrategy strategy = new(_ => true, minimumPreserved: 1); + MessageCompactionContextProvider provider = new(strategy); + + Mock mockAgent = new() { CallBase = true }; + TestAgentSession session = new(); + + List messages1 = + [ + new ChatMessage(ChatRole.User, "Q1"), + new ChatMessage(ChatRole.Assistant, "A1"), + new ChatMessage(ChatRole.User, "Q2"), + ]; + + AIContextProvider.InvokingContext context1 = new( + mockAgent.Object, + session, + new AIContext { Messages = messages1 }); + + // First call — initializes state + await provider.InvokingAsync(context1); + + 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"), + ]; + + AIContextProvider.InvokingContext context2 = new( + mockAgent.Object, + session, + new AIContext { Messages = messages2 }); + + // Act — second call exercises the update path + AIContext result = await provider.InvokingAsync(context2); + + // Assert + Assert.NotNull(result.Messages); + } + + [Fact] + public async Task InvokingAsyncWithNonListEnumerableCreatesListCopyAsync() + { + // Arrange — pass IEnumerable (not List) to exercise the list copy branch + TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(100000)); + MessageCompactionContextProvider provider = new(strategy); + + Mock mockAgent = new() { CallBase = true }; + TestAgentSession session = new(); + + // Use an IEnumerable (not a List) to trigger the copy path + IEnumerable messages = [new ChatMessage(ChatRole.User, "Hello")]; + + AIContextProvider.InvokingContext context = new( + mockAgent.Object, + session, + new AIContext { Messages = messages }); + + // Act + AIContext result = await provider.InvokingAsync(context); + + // Assert + Assert.NotNull(result.Messages); + List resultList = [.. result.Messages!]; + Assert.Single(resultList); + Assert.Equal("Hello", resultList[0].Text); + } + + private sealed class TestAgentSession : AgentSession; +} From 601eddb745d85efea1a9896c6698a84f76de2e8f Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Fri, 6 Mar 2026 11:24:09 -0800 Subject: [PATCH 25/80] Boundary condition --- .../Program.cs | 2 +- .../Compaction/CompactionStrategy.cs | 2 +- .../Compaction/MessageIndex.cs | 5 ++ .../Compaction/CompactionStrategyTests.cs | 81 +++++++++++++++++-- .../PipelineCompactionStrategyTests.cs | 24 +++++- .../ToolResultCompactionStrategyTests.cs | 3 +- 6 files changed, 104 insertions(+), 13 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 472dd4cb0a..90448741f6 100644 --- a/dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/Program.cs +++ b/dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/Program.cs @@ -41,7 +41,7 @@ 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. PipelineCompactionStrategy compactionPipeline = new(// 1. Gentle: collapse old tool-call groups into short summaries like "[Tool calls: LookupPrice]" - new ToolResultCompactionStrategy(CompactionTriggers.TokensExceed(0x200)), + new ToolResultCompactionStrategy(CompactionTriggers.MessagesExceed(7)), // 2. Moderate: use an LLM to summarize older conversation spans into a concise message new SummarizationCompactionStrategy(summarizerChatClient, CompactionTriggers.TokensExceed(0x500)), diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionStrategy.cs index 489fb44e0a..193b600fe3 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionStrategy.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionStrategy.cs @@ -84,7 +84,7 @@ protected CompactionStrategy(CompactionTrigger trigger, CompactionTrigger? targe /// 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)) + if (index.IncludedNonSystemGroupCount <= 1 || !this.Trigger(index)) { return false; } diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/MessageIndex.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/MessageIndex.cs index 3de2efa390..b4518697b7 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/MessageIndex.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/MessageIndex.cs @@ -296,6 +296,11 @@ public IEnumerable GetIncludedMessages() => /// public int IncludedTurnCount => this.Groups.Where(group => !group.IsExcluded).Select(group => group.TurnIndex).Distinct().Count(turnIndex => turnIndex is not null); + /// + /// Gets the total number of groups across all included (non-excluded) groups that are not . + /// + public int IncludedNonSystemGroupCount => this.Groups.Count(g => !g.IsExcluded && g.Kind != MessageGroupKind.System); + /// /// Returns all groups that belong to the specified user turn. /// diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionStrategyTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionStrategyTests.cs index 8aa1f30637..1f4750009e 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionStrategyTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionStrategyTests.cs @@ -23,9 +23,13 @@ public void ConstructorNullTriggerThrows() [Fact] public async Task CompactAsyncTriggerNotMetReturnsFalseAsync() { - // Arrange — trigger never fires + // Arrange — trigger never fires, but enough non-system groups to pass short-circuit TestStrategy strategy = new(_ => false); - MessageIndex index = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]); + MessageIndex index = MessageIndex.Create( + [ + new ChatMessage(ChatRole.User, "Hello"), + new ChatMessage(ChatRole.Assistant, "Hi!"), + ]); // Act bool result = await strategy.CompactAsync(index); @@ -38,9 +42,13 @@ public async Task CompactAsyncTriggerNotMetReturnsFalseAsync() [Fact] public async Task CompactAsyncTriggerMetCallsApplyAsync() { - // Arrange — trigger always fires + // Arrange — trigger always fires, enough non-system groups TestStrategy strategy = new(_ => true, applyFunc: _ => true); - MessageIndex index = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]); + MessageIndex index = MessageIndex.Create( + [ + new ChatMessage(ChatRole.User, "Hello"), + new ChatMessage(ChatRole.Assistant, "Hi!"), + ]); // Act bool result = await strategy.CompactAsync(index); @@ -55,7 +63,11 @@ 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")]); + MessageIndex index = MessageIndex.Create( + [ + new ChatMessage(ChatRole.User, "Hello"), + new ChatMessage(ChatRole.Assistant, "Hi!"), + ]); // Act bool result = await strategy.CompactAsync(index); @@ -65,6 +77,59 @@ public async Task CompactAsyncReturnsFalseWhenApplyReturnsFalseAsync() Assert.Equal(1, strategy.ApplyCallCount); } + [Fact] + public async Task CompactAsyncSingleNonSystemGroupShortCircuitsAsync() + { + // Arrange — trigger would fire, but only 1 non-system group → short-circuit + TestStrategy strategy = new(_ => true, applyFunc: _ => true); + MessageIndex index = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]); + + // Act + bool result = await strategy.CompactAsync(index); + + // Assert — short-circuited before trigger or Apply + Assert.False(result); + Assert.Equal(0, strategy.ApplyCallCount); + } + + [Fact] + public async Task CompactAsyncSingleNonSystemGroupWithSystemShortCircuitsAsync() + { + // Arrange — system group + 1 non-system group → still short-circuits + TestStrategy strategy = new(_ => true, applyFunc: _ => true); + MessageIndex index = MessageIndex.Create( + [ + new ChatMessage(ChatRole.System, "You are helpful."), + new ChatMessage(ChatRole.User, "Hello"), + ]); + + // Act + bool result = await strategy.CompactAsync(index); + + // Assert — system groups don't count, still only 1 non-system group + Assert.False(result); + Assert.Equal(0, strategy.ApplyCallCount); + } + + [Fact] + public async Task CompactAsyncTwoNonSystemGroupsProceedsToTriggerAsync() + { + // Arrange — exactly 2 non-system groups: boundary passes, trigger fires + TestStrategy strategy = new(_ => true, applyFunc: _ => true); + MessageIndex index = MessageIndex.Create( + [ + new ChatMessage(ChatRole.User, "Hello"), + new ChatMessage(ChatRole.Assistant, "Hi!"), + ]); + + // Act + bool result = await strategy.CompactAsync(index); + + // Assert — not short-circuited, Apply was called + Assert.True(result); + Assert.Equal(1, strategy.ApplyCallCount); + } + [Fact] public async Task CompactAsyncDefaultTargetIsInverseOfTriggerAsync() { @@ -121,7 +186,11 @@ bool CustomTarget(MessageIndex _) return true; }); - MessageIndex index = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]); + MessageIndex index = MessageIndex.Create( + [ + new ChatMessage(ChatRole.User, "Hello"), + new ChatMessage(ChatRole.Assistant, "Hi!"), + ]); // Act await strategy.CompactAsync(index); diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/PipelineCompactionStrategyTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/PipelineCompactionStrategyTests.cs index 0bb7b022dc..7d60339a89 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/PipelineCompactionStrategyTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/PipelineCompactionStrategyTests.cs @@ -34,7 +34,11 @@ public async Task CompactAsyncExecutesAllStrategiesInOrderAsync() }); PipelineCompactionStrategy pipeline = new(strategy1, strategy2); - MessageIndex groups = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]); + MessageIndex groups = MessageIndex.Create( + [ + new ChatMessage(ChatRole.User, "Hello"), + new ChatMessage(ChatRole.Assistant, "Hi!"), + ]); // Act await pipeline.CompactAsync(groups); @@ -50,7 +54,11 @@ public async Task CompactAsyncReturnsFalseWhenNoStrategyCompactsAsync() TestCompactionStrategy strategy1 = new(_ => false); PipelineCompactionStrategy pipeline = new(strategy1); - MessageIndex groups = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]); + MessageIndex groups = MessageIndex.Create( + [ + new ChatMessage(ChatRole.User, "Hello"), + new ChatMessage(ChatRole.Assistant, "Hi!"), + ]); // Act bool result = await pipeline.CompactAsync(groups); @@ -67,7 +75,11 @@ public async Task CompactAsyncReturnsTrueWhenAnyStrategyCompactsAsync() TestCompactionStrategy strategy2 = new(_ => true); PipelineCompactionStrategy pipeline = new(strategy1, strategy2); - MessageIndex groups = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]); + MessageIndex groups = MessageIndex.Create( + [ + new ChatMessage(ChatRole.User, "Hello"), + new ChatMessage(ChatRole.Assistant, "Hi!"), + ]); // Act bool result = await pipeline.CompactAsync(groups); @@ -84,7 +96,11 @@ public async Task CompactAsyncContinuesAfterFirstCompactionAsync() TestCompactionStrategy strategy2 = new(_ => false); PipelineCompactionStrategy pipeline = new(strategy1, strategy2); - MessageIndex groups = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]); + MessageIndex groups = MessageIndex.Create( + [ + new ChatMessage(ChatRole.User, "Hello"), + new ChatMessage(ChatRole.Assistant, "Hi!"), + ]); // Act await pipeline.CompactAsync(groups); diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/ToolResultCompactionStrategyTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/ToolResultCompactionStrategyTests.cs index 3d4a34ac54..346d8d2fb3 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/ToolResultCompactionStrategyTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/ToolResultCompactionStrategyTests.cs @@ -243,13 +243,14 @@ public async Task CompactAsyncSkipsPreExcludedAndSystemGroupsAsync() List messages = [ new ChatMessage(ChatRole.System, "System prompt"), + new ChatMessage(ChatRole.User, "Q0"), 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 + // Pre-exclude the last user group index.Groups[index.Groups.Count - 1].IsExcluded = true; // Act From 094b415fe2ce2f0712948b248690bb9c1c42e215 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Fri, 6 Mar 2026 11:37:05 -0800 Subject: [PATCH 26/80] Fix encoding --- .../Compaction/MessageCompactionContextProvider.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/MessageCompactionContextProvider.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/MessageCompactionContextProvider.cs index a506af9e88..88ce6d25a4 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/MessageCompactionContextProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/MessageCompactionContextProvider.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System.Collections.Generic; using System.Diagnostics; From 93728c13ccc2ae64ae574b9fb0b269ec3e59c40a Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Fri, 6 Mar 2026 11:37:47 -0800 Subject: [PATCH 27/80] Fix cast --- .../Compaction/MessageCompactionContextProvider.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/MessageCompactionContextProvider.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/MessageCompactionContextProvider.cs index 88ce6d25a4..52cb96bf0e 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/MessageCompactionContextProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/MessageCompactionContextProvider.cs @@ -108,7 +108,7 @@ protected override async ValueTask InvokingCoreAsync(AIContextProvide return new AIContext { Instructions = context.AIContext.Instructions, - Messages = wasCompacted ? messageIndex.GetIncludedMessages() : (IEnumerable)messageList, + Messages = wasCompacted ? messageIndex.GetIncludedMessages() : messageList, Tools = context.AIContext.Tools }; } From aff1d066ff8660799258100e97a2cfd8f6f38fbe Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Fri, 6 Mar 2026 11:49:30 -0800 Subject: [PATCH 28/80] Test coverage --- .../ChatReducerCompactionStrategy.cs | 5 +- .../Compaction/MessageIndex.cs | 4 +- .../SummarizationCompactionStrategy.cs | 2 +- .../Compaction/MessageIndexTests.cs | 51 +++++++++++++++++++ 4 files changed, 55 insertions(+), 7 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/ChatReducerCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/ChatReducerCompactionStrategy.cs index de1b7481d0..0e6a54e457 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/ChatReducerCompactionStrategy.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/ChatReducerCompactionStrategy.cs @@ -64,11 +64,8 @@ public ChatReducerCompactionStrategy(IChatReducer chatReducer, CompactionTrigger /// protected override async Task ApplyCompactionAsync(MessageIndex index, CancellationToken cancellationToken) { + // No need to short-circuit on empty conversations, this is handled by . 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]; diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/MessageIndex.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/MessageIndex.cs index b4518697b7..e59ed06907 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/MessageIndex.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/MessageIndex.cs @@ -319,7 +319,7 @@ public static int ComputeByteCount(IReadOnlyList messages) int total = 0; for (int i = 0; i < messages.Count; i++) { - string text = messages[i].Text ?? string.Empty; + string text = messages[i].Text; if (text.Length > 0) { total += Encoding.UTF8.GetByteCount(text); @@ -340,7 +340,7 @@ public static int ComputeTokenCount(IReadOnlyList messages, Tokeniz int total = 0; for (int i = 0; i < messages.Count; i++) { - string text = messages[i].Text ?? string.Empty; + string text = messages[i].Text; if (text.Length > 0) { total += tokenizer.CountTokens(text); diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/SummarizationCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/SummarizationCompactionStrategy.cs index d87a9e63a7..c61bb9f681 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/SummarizationCompactionStrategy.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/SummarizationCompactionStrategy.cs @@ -142,7 +142,7 @@ protected override async Task ApplyCompactionAsync(MessageIndex index, Can // Build text representation of the group for summarization foreach (ChatMessage message in group.Messages) { - string text = message.Text ?? string.Empty; + string text = message.Text; if (!string.IsNullOrEmpty(text)) { conversationText.AppendLine($"{message.Role}: {text}"); diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/MessageIndexTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/MessageIndexTests.cs index 395e6ff2bf..0ffb1a3486 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/MessageIndexTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/MessageIndexTests.cs @@ -830,6 +830,57 @@ public void CreateWithAssistantNonSummaryWithPropertiesFallsToAssistantText() Assert.Equal(MessageGroupKind.AssistantText, index.Groups[0].Kind); } + [Fact] + public void CreateWithSummaryPropertyFalseIsNotSummary() + { + // Summary property key present but value is false — not a summary + ChatMessage assistant = new(ChatRole.Assistant, "Not a summary"); + (assistant.AdditionalProperties ??= [])[MessageGroup.SummaryPropertyKey] = false; + + MessageIndex index = MessageIndex.Create([assistant]); + + Assert.Single(index.Groups); + Assert.Equal(MessageGroupKind.AssistantText, index.Groups[0].Kind); + } + + [Fact] + public void CreateWithSummaryPropertyNonBoolIsNotSummary() + { + // Summary property key present but value is a string, not a bool + ChatMessage assistant = new(ChatRole.Assistant, "Not a summary"); + (assistant.AdditionalProperties ??= [])[MessageGroup.SummaryPropertyKey] = "true"; + + MessageIndex index = MessageIndex.Create([assistant]); + + Assert.Single(index.Groups); + Assert.Equal(MessageGroupKind.AssistantText, index.Groups[0].Kind); + } + + [Fact] + public void CreateWithSummaryPropertyNullValueIsNotSummary() + { + // Summary property key present but value is null + ChatMessage assistant = new(ChatRole.Assistant, "Not a summary"); + (assistant.AdditionalProperties ??= [])[MessageGroup.SummaryPropertyKey] = null!; + + MessageIndex index = MessageIndex.Create([assistant]); + + Assert.Single(index.Groups); + Assert.Equal(MessageGroupKind.AssistantText, index.Groups[0].Kind); + } + + [Fact] + public void CreateWithNoAdditionalPropertiesIsNotSummary() + { + // Assistant message with no AdditionalProperties at all + ChatMessage assistant = new(ChatRole.Assistant, "Plain response"); + + MessageIndex index = MessageIndex.Create([assistant]); + + Assert.Single(index.Groups); + Assert.Equal(MessageGroupKind.AssistantText, index.Groups[0].Kind); + } + [Fact] public void ComputeByteCountHandlesNullAndNonNullText() { From 278912bb6832d402d799eadc5ff10ee7d16fb86e Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Fri, 6 Mar 2026 11:54:48 -0800 Subject: [PATCH 29/80] Namespace --- .../Compaction/MessageCompactionContextProviderTests.cs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/MessageCompactionContextProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/MessageCompactionContextProviderTests.cs index c402b70874..293954d267 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/MessageCompactionContextProviderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/MessageCompactionContextProviderTests.cs @@ -1,9 +1,7 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System; using System.Collections.Generic; -using System.Reflection; -using System.Threading; using System.Threading.Tasks; using Microsoft.Agents.AI.Compaction; using Microsoft.Extensions.AI; From 576f7508db3fe55533d3c37dab7d8e2872f6ddb6 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Fri, 6 Mar 2026 13:48:38 -0800 Subject: [PATCH 30/80] Improvements --- .../InMemoryChatHistoryProvider.cs | 2 -- .../ChatClient/ChatClientExtensions.cs | 14 +++++++------- .../Compaction/CompactingChatClient.cs | 14 +++++++++++--- 3 files changed, 18 insertions(+), 12 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/InMemoryChatHistoryProvider.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/InMemoryChatHistoryProvider.cs index 539a81ff73..1fba8ff3e4 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/InMemoryChatHistoryProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/InMemoryChatHistoryProvider.cs @@ -123,8 +123,6 @@ private async Task CompactMessagesAsync(State state, CancellationToken cancellat state.Messages = [.. await this.ChatReducer.ReduceAsync(state.Messages, cancellationToken).ConfigureAwait(false)]; return; } - - // %%% TODO: CONSIDER COMPACTION } /// diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientExtensions.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientExtensions.cs index a8d57ec3d0..2df5362c05 100644 --- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientExtensions.cs @@ -54,13 +54,6 @@ 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) => @@ -71,6 +64,13 @@ internal static IChatClient WithDefaultAgentMiddleware(this IChatClient chatClie }); } + // 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)); + } + var agentChatClient = chatBuilder.Build(services); if (options?.ChatOptions?.Tools is { Count: > 0 }) diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactingChatClient.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactingChatClient.cs index 29ac2e0994..f1d3e14f08 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactingChatClient.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactingChatClient.cs @@ -46,7 +46,7 @@ public CompactingChatClient(IChatClient innerClient, CompactionStrategy compacti this._compactionStrategy = Throw.IfNull(compactionStrategy); this._sessionState = new ProviderSessionState( _ => new State(), - Convert.ToBase64String(BitConverter.GetBytes(compactionStrategy.GetHashCode())), + $"{nameof(CompactingChatClient)}:{Convert.ToBase64String(BitConverter.GetBytes(compactionStrategy.GetHashCode()))}", AgentJsonUtilities.DefaultOptions); } @@ -87,7 +87,7 @@ public override async IAsyncEnumerable GetStreamingResponseA private async Task> ApplyCompactionAsync( IEnumerable messages, CancellationToken cancellationToken) { - List messageList = messages as List ?? [.. messages]; // %%% TODO - LIST COPY + List messageList = messages as List ?? [.. messages]; AgentRunContext? currentAgentContext = AIAgent.CurrentRunContext; if (currentAgentContext is null || @@ -97,6 +97,14 @@ private async Task> ApplyCompactionAsync( return messages; } + ChatClientAgentSession? chatClientSession = currentAgentContext.Session.GetService(); + if (chatClientSession is not null && + !string.IsNullOrWhiteSpace(chatClientSession.ConversationId)) + { + // Session is managed by remote service + return messages; + } + State state = this._sessionState.GetOrInitializeState(currentAgentContext.Session); MessageIndex messageIndex; @@ -121,7 +129,7 @@ private async Task> ApplyCompactionAsync( if (wasCompacted) { - state.MessageIndex = [.. messageIndex.Groups]; // %%% TODO - LIST COPY + state.MessageIndex = [.. messageIndex.Groups]; } return wasCompacted ? messageIndex.GetIncludedMessages() : messageList; From aa47a144b0b12638e3b025ab2d41ad99a6a43758 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Fri, 6 Mar 2026 15:55:59 -0800 Subject: [PATCH 31/80] Efficiency --- .../Compaction/CompactionTriggers.cs | 2 +- .../SlidingWindowCompactionStrategy.cs | 27 +++++++++---------- .../SummarizationCompactionStrategy.cs | 5 ++-- .../ToolResultCompactionStrategy.cs | 6 ++--- 4 files changed, 17 insertions(+), 23 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionTriggers.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionTriggers.cs index 53d3782a5f..e76258415b 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionTriggers.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionTriggers.cs @@ -24,7 +24,7 @@ public static class CompactionTriggers _ => true; /// - /// Always trigger compaction, regardless of the message index state. + /// Never trigger compaction, regardless of the message index state. /// public static readonly CompactionTrigger Never = _ => false; diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/SlidingWindowCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/SlidingWindowCompactionStrategy.cs index 11effccaa4..1378a4d465 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/SlidingWindowCompactionStrategy.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/SlidingWindowCompactionStrategy.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using System.Linq; using System.Threading; using System.Threading.Tasks; using Microsoft.Shared.DiagnosticIds; @@ -68,24 +69,19 @@ public SlidingWindowCompactionStrategy(CompactionTrigger trigger, int minimumPre protected override Task ApplyCompactionAsync(MessageIndex index, CancellationToken cancellationToken) { // Identify protected groups: the N most-recent non-system, non-excluded groups - List nonSystemIncludedIndices = []; - foreach (MessageGroup group in index.Groups) - { - if (!group.IsExcluded && group.Kind != MessageGroupKind.System) - { - nonSystemIncludedIndices.Add(index.Groups.IndexOf(group)); - } - } + int[] nonSystemIncludedIndices = + index.Groups + .Select((group, index) => (group, index)) + .Where(tuple => !tuple.group.IsExcluded && tuple.group.Kind != MessageGroupKind.System) + .Select(tuple => tuple.index) + .ToArray(); - int protectedStart = Math.Max(0, nonSystemIncludedIndices.Count - this.MinimumPreserved); - HashSet protectedGroupIndices = []; - for (int i = protectedStart; i < nonSystemIncludedIndices.Count; i++) - { - protectedGroupIndices.Add(nonSystemIncludedIndices[i]); - } + int protectedStart = Math.Max(0, nonSystemIncludedIndices.Length - this.MinimumPreserved); + HashSet protectedGroupIndices = [.. nonSystemIncludedIndices.Skip(protectedStart)]; // Collect distinct included turn indices in order (oldest first), excluding protected groups List excludableTurns = []; + HashSet processedTurns = []; for (int i = 0; i < index.Groups.Count; i++) { MessageGroup group = index.Groups[i]; @@ -93,9 +89,10 @@ protected override Task ApplyCompactionAsync(MessageIndex index, Cancellat && group.Kind != MessageGroupKind.System && !protectedGroupIndices.Contains(i) && group.TurnIndex is int turnIndex - && !excludableTurns.Contains(turnIndex)) + && !processedTurns.Contains(turnIndex)) { excludableTurns.Add(turnIndex); + processedTurns.Add(turnIndex); } } diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/SummarizationCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/SummarizationCompactionStrategy.cs index c61bb9f681..e6cbd210e4 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/SummarizationCompactionStrategy.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/SummarizationCompactionStrategy.cs @@ -28,9 +28,8 @@ namespace Microsoft.Agents.AI.Compaction; /// 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. +/// The predicate controls when compaction proceeds. Use +/// for common trigger conditions such as token thresholds. /// /// [Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/ToolResultCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/ToolResultCompactionStrategy.cs index 91737394ab..88a6e90d8a 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/ToolResultCompactionStrategy.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/ToolResultCompactionStrategy.cs @@ -27,10 +27,8 @@ namespace Microsoft.Agents.AI.Compaction; /// 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 -/// is used. +/// The predicate controls when compaction proceeds. Use +/// for common trigger conditions such as token thresholds. /// /// [Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] From b202b7c2a0d22380b3f7b91ce08271f9150f6647 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Fri, 6 Mar 2026 15:59:40 -0800 Subject: [PATCH 32/80] Cleanup --- .../Compaction/MessageCompactionContextProvider.cs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/MessageCompactionContextProvider.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/MessageCompactionContextProvider.cs index 52cb96bf0e..09e3a55135 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/MessageCompactionContextProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/MessageCompactionContextProvider.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using System; using System.Collections.Generic; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; @@ -43,13 +44,13 @@ public sealed class MessageCompactionContextProvider : MessageAIContextProvider /// An optional key used to store the provider state in the . /// Defaults to "MessageCompactionContextProvider". /// - /// is . + /// is . public MessageCompactionContextProvider(CompactionStrategy compactionStrategy, string? stateKey = null) { this._compactionStrategy = Throw.IfNull(compactionStrategy); this._sessionState = new ProviderSessionState( _ => new State(), - stateKey ?? nameof(MessageCompactionContextProvider), + stateKey ?? $"{nameof(MessageCompactionContextProvider)}:{Convert.ToBase64String(BitConverter.GetBytes(compactionStrategy.GetHashCode()))}", AgentJsonUtilities.DefaultOptions); } @@ -67,8 +68,8 @@ public MessageCompactionContextProvider(CompactionStrategy compactionStrategy, s /// protected override async ValueTask InvokingCoreAsync(AIContextProvider.InvokingContext context, CancellationToken cancellationToken = default) { - var session = context.Session; - var allMessages = context.AIContext.Messages; + AgentSession? session = context.Session; + IEnumerable? allMessages = context.AIContext.Messages; if (session is null || allMessages is null) { From da4886f71a14eae45c39cb2ffac697bff1912bce Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Fri, 6 Mar 2026 16:03:59 -0800 Subject: [PATCH 33/80] Detect service managed conversation --- .../Compaction/MessageCompactionContextProvider.cs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/MessageCompactionContextProvider.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/MessageCompactionContextProvider.cs index 09e3a55135..b18dede9ea 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/MessageCompactionContextProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/MessageCompactionContextProvider.cs @@ -7,6 +7,7 @@ using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; +using Google.Protobuf; using Microsoft.Extensions.AI; using Microsoft.Shared.DiagnosticIds; using Microsoft.Shared.Diagnostics; @@ -77,6 +78,14 @@ protected override async ValueTask InvokingCoreAsync(AIContextProvide return context.AIContext; } + ChatClientAgentSession? chatClientSession = session.GetService(); + if (chatClientSession is not null && + !string.IsNullOrWhiteSpace(chatClientSession.ConversationId)) + { + // Session is managed by remote service + return context.AIContext; + } + List messageList = allMessages as List ?? [.. allMessages]; State state = this._sessionState.GetOrInitializeState(session); From dcf4b1ae28a89d4cb80f4e4547c1c32a6a0d3335 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Fri, 6 Mar 2026 16:08:07 -0800 Subject: [PATCH 34/80] Fix namespace --- .../Compaction/MessageCompactionContextProvider.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/MessageCompactionContextProvider.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/MessageCompactionContextProvider.cs index b18dede9ea..bde7f45cc4 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/MessageCompactionContextProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/MessageCompactionContextProvider.cs @@ -7,7 +7,6 @@ using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; -using Google.Protobuf; using Microsoft.Extensions.AI; using Microsoft.Shared.DiagnosticIds; using Microsoft.Shared.Diagnostics; From fe09a1e6e59fe881c0365197b013d5150765fb31 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Fri, 6 Mar 2026 16:09:35 -0800 Subject: [PATCH 35/80] Fix merge --- .../Microsoft.Agents.AI/ChatClient/ChatClientExtensions.cs | 7 ------- 1 file changed, 7 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientExtensions.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientExtensions.cs index aa55dfe8b3..8290c39974 100644 --- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientExtensions.cs @@ -63,13 +63,6 @@ internal static IChatClient WithDefaultAgentMiddleware(this IChatClient chatClie }); } - // 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)); - } - var agentChatClient = chatBuilder.Build(services); if (options?.ChatOptions?.Tools is { Count: > 0 }) From b6070fd695b09c46d6a9e68766f272930bde1372 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Fri, 6 Mar 2026 16:11:11 -0800 Subject: [PATCH 36/80] Fix test expectation --- .../Compaction/MessageCompactionContextProviderTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/MessageCompactionContextProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/MessageCompactionContextProviderTests.cs index 293954d267..16a38e560b 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/MessageCompactionContextProviderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/MessageCompactionContextProviderTests.cs @@ -29,7 +29,7 @@ public void StateKeysReturnsExpectedKey() // Act & Assert — default state key is the class name Assert.Single(provider.StateKeys); - Assert.Equal(nameof(MessageCompactionContextProvider), provider.StateKeys[0]); + Assert.Contains(nameof(MessageCompactionContextProvider), provider.StateKeys[0]); } [Fact] From ff3d9e5020e26ca407b1cccf148fce847185636c Mon Sep 17 00:00:00 2001 From: Chris <66376200+crickman@users.noreply.github.com> Date: Mon, 9 Mar 2026 11:33:19 -0700 Subject: [PATCH 37/80] Update dotnet/src/Microsoft.Agents.AI.Abstractions/InMemoryChatHistoryProvider.cs Co-authored-by: westey <164392973+westey-m@users.noreply.github.com> --- .../InMemoryChatHistoryProvider.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/InMemoryChatHistoryProvider.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/InMemoryChatHistoryProvider.cs index 1fba8ff3e4..ce7806ef6a 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/InMemoryChatHistoryProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/InMemoryChatHistoryProvider.cs @@ -90,7 +90,6 @@ protected override async ValueTask> ProvideChatHistoryA { State state = this._sessionState.GetOrInitializeState(context.Session); - if (this.ReducerTriggerEvent is InMemoryChatHistoryProviderOptions.ChatReducerTriggerEvent.BeforeMessagesRetrieval && this.ChatReducer is not null) { // Apply pre-invocation compaction strategy if configured await this.CompactMessagesAsync(state, cancellationToken).ConfigureAwait(false); From 1fffafec1a988a309c482150d556bc21fc7fbcac Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Mon, 9 Mar 2026 15:18:28 -0700 Subject: [PATCH 38/80] Address PR comments (x1) --- .../Program.cs | 41 +++++++++++-------- .../ChatReducerCompactionStrategy.cs | 13 ++---- ...ntextProvider.cs => CompactionProvider.cs} | 28 +++++-------- .../Compaction/CompactionStrategy.cs | 8 ++-- .../Compaction/CompactionTrigger.cs | 7 ++-- .../Compaction/CompactionTriggers.cs | 25 +++++++++-- .../Compaction/MessageGroup.cs | 12 ++++-- .../Compaction/MessageIndex.cs | 40 +++++------------- .../Compaction/PipelineCompactionStrategy.cs | 2 +- .../SlidingWindowCompactionStrategy.cs | 4 +- .../SummarizationCompactionStrategy.cs | 2 +- .../ToolResultCompactionStrategy.cs | 6 +-- .../TruncationCompactionStrategy.cs | 6 +-- ...derTests.cs => CompactionProviderTests.cs} | 26 ++++++------ .../Compaction/CompactionStrategyTests.cs | 4 +- .../Compaction/MessageIndexTests.cs | 24 ++++++++++- .../PipelineCompactionStrategyTests.cs | 4 +- 17 files changed, 135 insertions(+), 117 deletions(-) rename dotnet/src/Microsoft.Agents.AI/Compaction/{MessageCompactionContextProvider.cs => CompactionProvider.cs} (79%) rename dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/{MessageCompactionContextProviderTests.cs => CompactionProviderTests.cs} (89%) 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 060c5132fb..b194e388ae 100644 --- a/dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/Program.cs +++ b/dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/Program.cs @@ -50,28 +50,37 @@ static string LookupPrice([Description("The product name to look up.")] string p // 3. Aggressive: keep only the last N user turns and their responses new SlidingWindowCompactionStrategy(CompactionTriggers.TurnsExceed(4)), + //MessageGroup + //MessageIndex + //CompactionTrigger + //CompactionTriggers.TurnsExceed(4) + // 4. Emergency: drop oldest groups until under the token budget new TruncationCompactionStrategy(CompactionTriggers.TokensExceed(0x8000))); // Create the agent with a MessageCompactionContextProvider that uses the compaction pipeline. AIAgent agent = - agentChatClient.AsAIAgent( - new ChatClientAgentOptions - { - Name = "ShoppingAssistant", - ChatOptions = new() + agentChatClient + .AsBuilder() + //.UseAIContextProviders(new CompactionProvider(compactionPipeline)) + .BuildAIAgent( + new ChatClientAgentOptions { - 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)], - }, - AIContextProviders = [new MessageCompactionContextProvider(compactionPipeline)], - }); + 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)] + }, + // Note: AIContextProviders may be specified here instead of ChatClientBuilder.UseAIContextProviders. + AIContextProviders = [new CompactionProvider(compactionPipeline)] + }); AgentSession session = await agent.CreateSessionAsync(); diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/ChatReducerCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/ChatReducerCompactionStrategy.cs index 0e6a54e457..94b77a8771 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/ChatReducerCompactionStrategy.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/ChatReducerCompactionStrategy.cs @@ -43,15 +43,8 @@ public sealed class ChatReducerCompactionStrategy : CompactionStrategy /// /// 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) + public ChatReducerCompactionStrategy(IChatReducer chatReducer, CompactionTrigger trigger) + : base(trigger) { this.ChatReducer = Throw.IfNull(chatReducer); } @@ -62,7 +55,7 @@ public ChatReducerCompactionStrategy(IChatReducer chatReducer, CompactionTrigger public IChatReducer ChatReducer { get; } /// - protected override async Task ApplyCompactionAsync(MessageIndex index, CancellationToken cancellationToken) + protected override async ValueTask CompactCoreAsync(MessageIndex index, CancellationToken cancellationToken) { // No need to short-circuit on empty conversations, this is handled by . List includedMessages = [.. index.GetIncludedMessages()]; diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/MessageCompactionContextProvider.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionProvider.cs similarity index 79% rename from dotnet/src/Microsoft.Agents.AI/Compaction/MessageCompactionContextProvider.cs rename to dotnet/src/Microsoft.Agents.AI/Compaction/CompactionProvider.cs index bde7f45cc4..adc6434487 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/MessageCompactionContextProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionProvider.cs @@ -2,7 +2,6 @@ using System; using System.Collections.Generic; -using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Text.Json.Serialization; using System.Threading; @@ -24,20 +23,20 @@ namespace Microsoft.Agents.AI.Compaction; /// to the agent's underlying chat client. /// /// -/// The can be added to an agent's context provider pipeline +/// The can be added to an agent's context provider pipeline /// via or via UseAIContextProviders /// on a or . /// /// [Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] -public sealed class MessageCompactionContextProvider : MessageAIContextProvider +public sealed class CompactionProvider : AIContextProvider { private readonly CompactionStrategy _compactionStrategy; private readonly ProviderSessionState _sessionState; private IReadOnlyList? _stateKeys; /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// /// The compaction strategy to apply before each invocation. /// @@ -45,12 +44,12 @@ public sealed class MessageCompactionContextProvider : MessageAIContextProvider /// Defaults to "MessageCompactionContextProvider". /// /// is . - public MessageCompactionContextProvider(CompactionStrategy compactionStrategy, string? stateKey = null) + public CompactionProvider(CompactionStrategy compactionStrategy, string? stateKey = null) { this._compactionStrategy = Throw.IfNull(compactionStrategy); this._sessionState = new ProviderSessionState( _ => new State(), - stateKey ?? $"{nameof(MessageCompactionContextProvider)}:{Convert.ToBase64String(BitConverter.GetBytes(compactionStrategy.GetHashCode()))}", + stateKey ?? $"{nameof(CompactionProvider)}:{Convert.ToBase64String(BitConverter.GetBytes(compactionStrategy.GetHashCode()))}", AgentJsonUtilities.DefaultOptions); } @@ -66,7 +65,7 @@ public MessageCompactionContextProvider(CompactionStrategy compactionStrategy, s /// A task that represents the asynchronous operation. The task result contains an /// with the compacted message list. If no compaction was needed, the original context is returned unchanged. /// - protected override async ValueTask InvokingCoreAsync(AIContextProvider.InvokingContext context, CancellationToken cancellationToken = default) + protected override async ValueTask InvokingCoreAsync(InvokingContext context, CancellationToken cancellationToken = default) { AgentSession? session = context.Session; IEnumerable? allMessages = context.AIContext.Messages; @@ -90,10 +89,10 @@ protected override async ValueTask InvokingCoreAsync(AIContextProvide State state = this._sessionState.GetOrInitializeState(session); MessageIndex messageIndex; - if (state.MessageIndex.Count > 0) + if (state.MessageGroups.Count > 0) { // Update existing index with any new messages appended since the last call. - messageIndex = new(state.MessageIndex); + messageIndex = new(state.MessageGroups); messageIndex.Update(messageList); } else @@ -103,15 +102,10 @@ protected override async ValueTask InvokingCoreAsync(AIContextProvide } // 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) { - state.MessageIndex = [.. messageIndex.Groups]; + state.MessageGroups = [.. messageIndex.Groups]; } return new AIContext @@ -123,7 +117,7 @@ protected override async ValueTask InvokingCoreAsync(AIContextProvide } /// - /// Represents the persisted state of a stored in the . + /// Represents the persisted state of a stored in the . /// public sealed class State { @@ -131,6 +125,6 @@ public sealed class State /// Gets or sets the message index groups used for incremental compaction updates. /// [JsonPropertyName("messages")] - public List MessageIndex { get; set; } = []; + public List MessageGroups { get; set; } = []; } } diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionStrategy.cs index 193b600fe3..2902522da8 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionStrategy.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionStrategy.cs @@ -77,12 +77,12 @@ protected CompactionStrategy(CompactionTrigger trigger, CompactionTrigger? targe /// /// Evaluates the and, when it fires, delegates to - /// and reports compaction metrics. + /// 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) + public async ValueTask CompactAsync(MessageIndex index, CancellationToken cancellationToken = default) { if (index.IncludedNonSystemGroupCount <= 1 || !this.Trigger(index)) { @@ -95,7 +95,7 @@ public async Task CompactAsync(MessageIndex index, CancellationToken cance Stopwatch stopwatch = Stopwatch.StartNew(); - bool compacted = await this.ApplyCompactionAsync(index, cancellationToken).ConfigureAwait(false); + bool compacted = await this.CompactCoreAsync(index, cancellationToken).ConfigureAwait(false); stopwatch.Stop(); @@ -126,5 +126,5 @@ public async Task CompactAsync(MessageIndex index, CancellationToken cance /// 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); + protected abstract ValueTask CompactCoreAsync(MessageIndex index, CancellationToken cancellationToken); } diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionTrigger.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionTrigger.cs index 4803c6a6fa..dbbcc21325 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionTrigger.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionTrigger.cs @@ -6,9 +6,10 @@ namespace Microsoft.Agents.AI.Compaction; /// -/// A predicate that evaluates whether compaction should proceed based on current metrics. +/// Defines a condition based on metrics used by a +/// to determine when to trigger compaction and when the target compaction threshold has been met. /// -/// The current message index with group, token, message, and turn metrics. -/// if compaction should proceed; to skip. +/// An index over conversation messages that provides group, token, message, and turn metrics. +/// to the trigger that the condition has been met; otherwise . [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 e76258415b..c72b3b088d 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionTriggers.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionTriggers.cs @@ -7,12 +7,17 @@ namespace Microsoft.Agents.AI.Compaction; /// -/// Provides factory methods for common predicates. +/// Factory to create 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. +/// +/// A defines a condition based on metrics used +/// by a to determine when to trigger compaction and when the target +/// compaction threshold has been met. +/// +/// +/// Combine triggers with or for compound conditions. +/// /// [Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] public static class CompactionTriggers @@ -58,6 +63,18 @@ public static CompactionTrigger MessagesExceed(int maxMessages) => /// /// The turn threshold. Compaction proceeds when included turns exceed this value. /// A that evaluates included turn count. + /// + /// + /// A user turn starts with a group and includes all subsequent + /// non-user, non-system groups until the next user group or end of conversation. Each group is assigned + /// a indicating which user turn it belongs to. + /// System messages () are always assigned a + /// since they never belong to a user turn. + /// + /// + /// The turn count is the number of distinct values defined by . + /// + /// public static CompactionTrigger TurnsExceed(int maxTurns) => index => index.IncludedTurnCount > maxTurns; diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/MessageGroup.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/MessageGroup.cs index df1098c7da..2fd8cc02fe 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/MessageGroup.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/MessageGroup.cs @@ -25,7 +25,6 @@ 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. /// /// [Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] @@ -52,7 +51,7 @@ public sealed class MessageGroup /// the first user message (e.g., system messages). /// [JsonConstructor] - public MessageGroup(MessageGroupKind kind, IReadOnlyList messages, int byteCount, int tokenCount, int? turnIndex = null) + internal MessageGroup(MessageGroupKind kind, IReadOnlyList messages, int byteCount, int tokenCount, int? turnIndex = null) { this.Kind = kind; this.Messages = messages; @@ -89,11 +88,16 @@ public MessageGroup(MessageGroupKind kind, IReadOnlyList messages, /// /// Gets the zero-based user turn index this group belongs to, or - /// for groups that precede the first user message (e.g., system messages). + /// for groups that precede the first user message (e.g., system messages). A turn index + /// of 0 corresponds with any non-system message that preceeds the first user message, + /// turn index 1 corresponds with the first user message and its subsequent non-user + /// messages, and so on. /// /// /// A turn starts with a group and includes all subsequent - /// non-user, non-system groups until the next user group or end of conversation. + /// non-user, non-system groups until the next user group or end of conversation. System messages + /// () are always assigned a turn index + /// since they never belong to a user turn. /// public int? TurnIndex { get; } diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/MessageIndex.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/MessageIndex.cs index e59ed06907..7c4496b681 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/MessageIndex.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/MessageIndex.cs @@ -12,29 +12,13 @@ namespace Microsoft.Agents.AI.Compaction; /// -/// Represents a collection of instances derived from a flat list of objects. +/// A collection of instances and derived metrics based on 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. -/// -/// -/// 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. -/// +/// provides structural grouping of messages into logical units. Individual +/// groups can be marked as excluded without being removed, allowing compaction strategies to toggle visibility while preserving +/// the full history for diagnostics or storage. Metrics are provided both including and excluding excluded groups, +/// allowing strategies to make informed decisions based on the impact of potential exclusions. /// [Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] public sealed class MessageIndex @@ -54,10 +38,6 @@ public sealed class MessageIndex /// /// 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; } /// @@ -100,7 +80,7 @@ public MessageIndex(IList groups, Tokenizer? tokenizer = null) /// Assistant messages without tool calls become groups. /// /// - public static MessageIndex Create(IList messages, Tokenizer? tokenizer = null) + internal static MessageIndex Create(IList messages, Tokenizer? tokenizer = null) { Debug.WriteLine("COMPACTION: Creating index x{messages.Count} messages"); MessageIndex instance = new([], tokenizer); @@ -129,7 +109,7 @@ public static MessageIndex Create(IList messages, Tokenizer? tokeni /// If the message count equals , no work is performed. /// /// - public void Update(IList allMessages) + internal void Update(IList allMessages) { if (allMessages.Count == this.ProcessedMessageCount) { @@ -294,7 +274,7 @@ public IEnumerable GetIncludedMessages() => /// /// 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); + public int IncludedTurnCount => this.Groups.Where(group => !group.IsExcluded && group.TurnIndex > 0).Select(group => group.TurnIndex).Distinct().Count(turnIndex => turnIndex is not null); /// /// Gets the total number of groups across all included (non-excluded) groups that are not . @@ -314,7 +294,7 @@ public IEnumerable GetTurnGroups(int turnIndex) => /// /// The messages to compute byte count for. /// The total UTF-8 byte count of all message text content. - public static int ComputeByteCount(IReadOnlyList messages) + internal static int ComputeByteCount(IReadOnlyList messages) { int total = 0; for (int i = 0; i < messages.Count; i++) @@ -335,7 +315,7 @@ public static int ComputeByteCount(IReadOnlyList messages) /// 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) + internal static int ComputeTokenCount(IReadOnlyList messages, Tokenizer tokenizer) { int total = 0; for (int i = 0; i < messages.Count; i++) diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/PipelineCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/PipelineCompactionStrategy.cs index fa23239156..e49b8f2217 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/PipelineCompactionStrategy.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/PipelineCompactionStrategy.cs @@ -43,7 +43,7 @@ public PipelineCompactionStrategy(params IEnumerable strateg public IReadOnlyList Strategies { get; } /// - protected override async Task ApplyCompactionAsync(MessageIndex index, CancellationToken cancellationToken) + protected override async ValueTask CompactCoreAsync(MessageIndex index, CancellationToken cancellationToken) { bool anyCompacted = false; diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/SlidingWindowCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/SlidingWindowCompactionStrategy.cs index 1378a4d465..6bedbbad2c 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/SlidingWindowCompactionStrategy.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/SlidingWindowCompactionStrategy.cs @@ -66,7 +66,7 @@ public SlidingWindowCompactionStrategy(CompactionTrigger trigger, int minimumPre public int MinimumPreserved { get; } /// - protected override Task ApplyCompactionAsync(MessageIndex index, CancellationToken cancellationToken) + protected override ValueTask CompactCoreAsync(MessageIndex index, CancellationToken cancellationToken) { // Identify protected groups: the N most-recent non-system, non-excluded groups int[] nonSystemIncludedIndices = @@ -125,6 +125,6 @@ protected override Task ApplyCompactionAsync(MessageIndex index, Cancellat } } - return Task.FromResult(compacted); + return new ValueTask(compacted); } } diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/SummarizationCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/SummarizationCompactionStrategy.cs index e6cbd210e4..dee89b6398 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/SummarizationCompactionStrategy.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/SummarizationCompactionStrategy.cs @@ -99,7 +99,7 @@ public SummarizationCompactionStrategy( public string SummarizationPrompt { get; } /// - protected override async Task ApplyCompactionAsync(MessageIndex index, CancellationToken cancellationToken) + protected override async ValueTask CompactCoreAsync(MessageIndex index, CancellationToken cancellationToken) { // Count non-system, non-excluded groups to determine which are protected int nonSystemIncludedCount = 0; diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/ToolResultCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/ToolResultCompactionStrategy.cs index 88a6e90d8a..b609d7e751 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/ToolResultCompactionStrategy.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/ToolResultCompactionStrategy.cs @@ -68,7 +68,7 @@ public ToolResultCompactionStrategy(CompactionTrigger trigger, int minimumPreser public int MinimumPreserved { get; } /// - protected override Task ApplyCompactionAsync(MessageIndex index, CancellationToken cancellationToken) + protected override ValueTask CompactCoreAsync(MessageIndex index, CancellationToken cancellationToken) { // Identify protected groups: the N most-recent non-system, non-excluded groups List nonSystemIncludedIndices = []; @@ -101,7 +101,7 @@ protected override Task ApplyCompactionAsync(MessageIndex index, Cancellat if (eligibleIndices.Count == 0) { - return Task.FromResult(false); + return new ValueTask(false); } // Collapse one tool group at a time from oldest, re-checking target after each @@ -146,6 +146,6 @@ protected override Task ApplyCompactionAsync(MessageIndex index, Cancellat } } - return Task.FromResult(compacted); + return new ValueTask(compacted); } } diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/TruncationCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/TruncationCompactionStrategy.cs index 46960561fc..81670560b2 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/TruncationCompactionStrategy.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/TruncationCompactionStrategy.cs @@ -62,7 +62,7 @@ public TruncationCompactionStrategy(CompactionTrigger trigger, int minimumPreser public int MinimumPreserved { get; } /// - protected override Task ApplyCompactionAsync(MessageIndex index, CancellationToken cancellationToken) + protected override ValueTask CompactCoreAsync(MessageIndex index, CancellationToken cancellationToken) { // Count removable (non-system, non-excluded) groups int removableCount = 0; @@ -78,7 +78,7 @@ protected override Task ApplyCompactionAsync(MessageIndex index, Cancellat int maxRemovable = removableCount - this.MinimumPreserved; if (maxRemovable <= 0) { - return Task.FromResult(false); + return new ValueTask(false); } // Exclude oldest non-system groups one at a time, re-checking target after each @@ -104,6 +104,6 @@ protected override Task ApplyCompactionAsync(MessageIndex index, Cancellat } } - return Task.FromResult(compacted); + return new ValueTask(compacted); } } diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/MessageCompactionContextProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionProviderTests.cs similarity index 89% rename from dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/MessageCompactionContextProviderTests.cs rename to dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionProviderTests.cs index 16a38e560b..e2f1c2ecd5 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/MessageCompactionContextProviderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionProviderTests.cs @@ -10,14 +10,14 @@ namespace Microsoft.Agents.AI.UnitTests.Compaction; /// -/// Contains tests for the class. +/// Contains tests for the class. /// -public sealed class MessageCompactionContextProviderTests +public sealed class CompactionProviderTests { [Fact] public void ConstructorThrowsOnNullStrategy() { - Assert.Throws(() => new MessageCompactionContextProvider(null!)); + Assert.Throws(() => new CompactionProvider(null!)); } [Fact] @@ -25,11 +25,11 @@ public void StateKeysReturnsExpectedKey() { // Arrange TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(100000)); - MessageCompactionContextProvider provider = new(strategy); + CompactionProvider provider = new(strategy); // Act & Assert — default state key is the class name Assert.Single(provider.StateKeys); - Assert.Contains(nameof(MessageCompactionContextProvider), provider.StateKeys[0]); + Assert.Contains(nameof(CompactionProvider), provider.StateKeys[0]); } [Fact] @@ -37,7 +37,7 @@ public void StateKeysReturnsCustomKeyWhenProvided() { // Arrange TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(100000)); - MessageCompactionContextProvider provider = new(strategy, stateKey: "my-custom-key"); + CompactionProvider provider = new(strategy, stateKey: "my-custom-key"); // Act & Assert Assert.Single(provider.StateKeys); @@ -49,7 +49,7 @@ public async Task InvokingAsyncNoSessionPassesThroughAsync() { // Arrange — no session → passthrough TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(100000)); - MessageCompactionContextProvider provider = new(strategy); + CompactionProvider provider = new(strategy); Mock mockAgent = new() { CallBase = true }; List messages = @@ -74,7 +74,7 @@ public async Task InvokingAsyncNullMessagesPassesThroughAsync() { // Arrange — messages is null → passthrough TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(100000)); - MessageCompactionContextProvider provider = new(strategy); + CompactionProvider provider = new(strategy); Mock mockAgent = new() { CallBase = true }; TestAgentSession session = new(); @@ -95,7 +95,7 @@ public async Task InvokingAsyncAppliesCompactionWhenTriggeredAsync() { // Arrange — strategy that always triggers and keeps only 1 group TruncationCompactionStrategy strategy = new(_ => true, minimumPreserved: 1); - MessageCompactionContextProvider provider = new(strategy); + CompactionProvider provider = new(strategy); Mock mockAgent = new() { CallBase = true }; TestAgentSession session = new(); @@ -125,7 +125,7 @@ public async Task InvokingAsyncNoCompactionNeededReturnsOriginalMessagesAsync() { // Arrange — trigger never fires → no compaction TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(100000)); - MessageCompactionContextProvider provider = new(strategy); + CompactionProvider provider = new(strategy); Mock mockAgent = new() { CallBase = true }; TestAgentSession session = new(); @@ -154,7 +154,7 @@ public async Task InvokingAsyncPreservesInstructionsAndToolsAsync() { // Arrange TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(100000)); - MessageCompactionContextProvider provider = new(strategy); + CompactionProvider provider = new(strategy); Mock mockAgent = new() { CallBase = true }; TestAgentSession session = new(); @@ -184,7 +184,7 @@ public async Task InvokingAsyncWithExistingIndexUpdatesAsync() { // Arrange — call twice to exercise the "existing index" path TruncationCompactionStrategy strategy = new(_ => true, minimumPreserved: 1); - MessageCompactionContextProvider provider = new(strategy); + CompactionProvider provider = new(strategy); Mock mockAgent = new() { CallBase = true }; TestAgentSession session = new(); @@ -230,7 +230,7 @@ public async Task InvokingAsyncWithNonListEnumerableCreatesListCopyAsync() { // Arrange — pass IEnumerable (not List) to exercise the list copy branch TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(100000)); - MessageCompactionContextProvider provider = new(strategy); + CompactionProvider provider = new(strategy); Mock mockAgent = new() { CallBase = true }; TestAgentSession session = new(); diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionStrategyTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionStrategyTests.cs index 1f4750009e..8c6ee9e0ae 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionStrategyTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionStrategyTests.cs @@ -225,11 +225,11 @@ public TestStrategy( /// public bool InvokeTarget(MessageIndex index) => this.Target(index); - protected override Task ApplyCompactionAsync(MessageIndex index, CancellationToken cancellationToken) + protected override ValueTask CompactCoreAsync(MessageIndex index, CancellationToken cancellationToken) { this.ApplyCallCount++; bool result = this._applyFunc?.Invoke(index) ?? false; - return Task.FromResult(result); + return new(result); } } } diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/MessageIndexTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/MessageIndexTests.cs index 0ffb1a3486..3c3654da39 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/MessageIndexTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/MessageIndexTests.cs @@ -79,11 +79,31 @@ public void CreateAssistantTextMessageCreatesAssistantTextGroup() } [Fact] - public void CreateToolCallWithResultsCreatesAtomicToolCallGroup() + public void CreateToolCallWithResultsCreatesAtomicGroup() { // Arrange ChatMessage assistantMessage = new(ChatRole.Assistant, [new FunctionCallContent("call1", "get_weather", new Dictionary { ["city"] = "Seattle" })]); - ChatMessage toolResult = new(ChatRole.Tool, "Sunny, 72°F"); + ChatMessage toolResult = new(ChatRole.Tool, [new FunctionResultContent("call1", "Sunny, 72°F")]); + + List messages = [assistantMessage, toolResult]; + + // Act + MessageIndex groups = MessageIndex.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 CreateToolCallWithTextCreatesAtomicGroup() + { + // Arrange + ChatMessage assistantMessage = new(ChatRole.Assistant, [new FunctionCallContent("call1", "get_weather", new Dictionary { ["city"] = "Seattle" })]); + ChatMessage toolResult = new(ChatRole.Tool, [new TextContent("Sunny, 72°F"), new FunctionResultContent("call1", "Sunny, 72°F")]); List messages = [assistantMessage, toolResult]; diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/PipelineCompactionStrategyTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/PipelineCompactionStrategyTests.cs index 7d60339a89..9c57db663c 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/PipelineCompactionStrategyTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/PipelineCompactionStrategyTests.cs @@ -198,10 +198,10 @@ public TestCompactionStrategy(Func applyFunc) public int ApplyCallCount { get; private set; } - protected override Task ApplyCompactionAsync(MessageIndex index, CancellationToken cancellationToken) + protected override ValueTask CompactCoreAsync(MessageIndex index, CancellationToken cancellationToken) { this.ApplyCallCount++; - return Task.FromResult(this._applyFunc(index)); + return new(this._applyFunc(index)); } } } From d6d2331b05492bf64c386db2c3c0c7c36b8e3b74 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Mon, 9 Mar 2026 15:31:29 -0700 Subject: [PATCH 39/80] Update comment --- .../Compaction/CompactionTriggers.cs | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionTriggers.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionTriggers.cs index c72b3b088d..0ad65ae42a 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionTriggers.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionTriggers.cs @@ -23,13 +23,13 @@ namespace Microsoft.Agents.AI.Compaction; public static class CompactionTriggers { /// - /// Always trigger compaction, regardless of the message index state. + /// Always trigger, regardless of the message index state. /// public static readonly CompactionTrigger Always = _ => true; /// - /// Never trigger compaction, regardless of the message index state. + /// Never trigger, regardless of the message index state. /// public static readonly CompactionTrigger Never = _ => false; @@ -37,15 +37,15 @@ public static class CompactionTriggers /// /// 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. + /// The token threshold. /// A that evaluates included token count. - public static CompactionTrigger TokensBelow(int maxTokens) => - index => index.IncludedTokenCount < maxTokens; + public static CompactionTrigger TokensBelow(int minTokens) => + index => index.IncludedTokenCount < minTokens; /// /// 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. + /// The token threshold. /// A that evaluates included token count. public static CompactionTrigger TokensExceed(int maxTokens) => index => index.IncludedTokenCount > maxTokens; @@ -53,7 +53,7 @@ public static CompactionTrigger TokensExceed(int 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. + /// The message threshold. /// A that evaluates included message count. public static CompactionTrigger MessagesExceed(int maxMessages) => index => index.IncludedMessageCount > maxMessages; @@ -61,7 +61,7 @@ public static CompactionTrigger MessagesExceed(int 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. + /// The turn threshold. /// A that evaluates included turn count. /// /// @@ -81,7 +81,7 @@ public static CompactionTrigger TurnsExceed(int 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. + /// The group threshold. /// A that evaluates included group count. public static CompactionTrigger GroupsExceed(int maxGroups) => index => index.IncludedGroupCount > maxGroups; From ee936d3b41edc3756f3775895508638b9672f343 Mon Sep 17 00:00:00 2001 From: Chris <66376200+crickman@users.noreply.github.com> Date: Mon, 9 Mar 2026 15:32:11 -0700 Subject: [PATCH 40/80] Update comments --- dotnet/src/Microsoft.Agents.AI/Compaction/CompactionProvider.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionProvider.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionProvider.cs index adc6434487..643176a1a3 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionProvider.cs @@ -41,7 +41,6 @@ public sealed class CompactionProvider : AIContextProvider /// The compaction strategy to apply before each invocation. /// /// An optional key used to store the provider state in the . - /// Defaults to "MessageCompactionContextProvider". /// /// is . public CompactionProvider(CompactionStrategy compactionStrategy, string? stateKey = null) From b6cbf62927eff645af40c0b0fdb883e646d111c6 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Mon, 9 Mar 2026 15:34:21 -0700 Subject: [PATCH 41/80] Clean-up --- .../Agents/Agent_Step18_CompactionPipeline/Program.cs | 9 ++------- 1 file changed, 2 insertions(+), 7 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 b194e388ae..c87da586f0 100644 --- a/dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/Program.cs +++ b/dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/Program.cs @@ -50,11 +50,6 @@ static string LookupPrice([Description("The product name to look up.")] string p // 3. Aggressive: keep only the last N user turns and their responses new SlidingWindowCompactionStrategy(CompactionTriggers.TurnsExceed(4)), - //MessageGroup - //MessageIndex - //CompactionTrigger - //CompactionTriggers.TurnsExceed(4) - // 4. Emergency: drop oldest groups until under the token budget new TruncationCompactionStrategy(CompactionTriggers.TokensExceed(0x8000))); @@ -62,7 +57,7 @@ static string LookupPrice([Description("The product name to look up.")] string p AIAgent agent = agentChatClient .AsBuilder() - //.UseAIContextProviders(new CompactionProvider(compactionPipeline)) + .UseAIContextProviders(new CompactionProvider(compactionPipeline)) .BuildAIAgent( new ChatClientAgentOptions { @@ -79,7 +74,7 @@ many words as possible without sounding ridiculous. Tools = [AIFunctionFactory.Create(LookupPrice)] }, // Note: AIContextProviders may be specified here instead of ChatClientBuilder.UseAIContextProviders. - AIContextProviders = [new CompactionProvider(compactionPipeline)] + //AIContextProviders = [new CompactionProvider(compactionPipeline)] }); AgentSession session = await agent.CreateSessionAsync(); From 6aeb295c9b3caccf06b12e21fcf5baf26e135dec Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Mon, 9 Mar 2026 15:36:15 -0700 Subject: [PATCH 42/80] Format output --- dotnet/src/Microsoft.Agents.AI/Compaction/MessageIndex.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/MessageIndex.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/MessageIndex.cs index 7c4496b681..cd41bb5ac2 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/MessageIndex.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/MessageIndex.cs @@ -82,7 +82,7 @@ public MessageIndex(IList groups, Tokenizer? tokenizer = null) /// internal static MessageIndex Create(IList messages, Tokenizer? tokenizer = null) { - Debug.WriteLine("COMPACTION: Creating index x{messages.Count} messages"); + Debug.WriteLine($"COMPACTION: Creating index x{messages.Count} messages"); MessageIndex instance = new([], tokenizer); instance.AppendFromMessages(messages, 0); return instance; From f88fed0bbe06b9dd6e0378715ad1ddfd7fc014d3 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Mon, 9 Mar 2026 15:37:18 -0700 Subject: [PATCH 43/80] Sync sample comment --- .../02-agents/Agents/Agent_Step18_CompactionPipeline/Program.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 c87da586f0..965f8fc3bb 100644 --- a/dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/Program.cs +++ b/dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/Program.cs @@ -1,6 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. -// This sample demonstrates how to use a MessageCompactionContextProvider with a compaction pipeline +// This sample demonstrates how to use a CompactionProvider with a compaction pipeline // as an AIContextProvider for an agent's in-run context management. The pipeline chains multiple // compaction strategies from gentle to aggressive: // 1. ToolResultCompactionStrategy - Collapses old tool-call groups into concise summaries From 9edd44005e40974d90b98c5a74e813e2d11920ba Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Mon, 9 Mar 2026 15:39:31 -0700 Subject: [PATCH 44/80] Fix condition --- .../InMemoryChatHistoryProvider.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/InMemoryChatHistoryProvider.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/InMemoryChatHistoryProvider.cs index ce7806ef6a..7597328c83 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/InMemoryChatHistoryProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/InMemoryChatHistoryProvider.cs @@ -90,6 +90,7 @@ protected override async ValueTask> ProvideChatHistoryA { State state = this._sessionState.GetOrInitializeState(context.Session); + if (this.ReducerTriggerEvent is InMemoryChatHistoryProviderOptions.ChatReducerTriggerEvent.BeforeMessagesRetrieval) { // Apply pre-invocation compaction strategy if configured await this.CompactMessagesAsync(state, cancellationToken).ConfigureAwait(false); @@ -118,7 +119,6 @@ private async Task CompactMessagesAsync(State state, CancellationToken cancellat { if (this.ChatReducer is not null) { - // ChatReducer takes precedence, if configured state.Messages = [.. await this.ChatReducer.ReduceAsync(state.Messages, cancellationToken).ConfigureAwait(false)]; return; } From f105ae0cb854b8f9b409159f7aef55c5836871ca Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Mon, 9 Mar 2026 15:46:48 -0700 Subject: [PATCH 45/80] Adjust data-flow --- .../Compaction/CompactionProvider.cs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionProvider.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionProvider.cs index 643176a1a3..75dffa82e6 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionProvider.cs @@ -101,16 +101,16 @@ protected override async ValueTask InvokingCoreAsync(InvokingContext } // Apply compaction - bool wasCompacted = await this._compactionStrategy.CompactAsync(messageIndex, cancellationToken).ConfigureAwait(false); - if (wasCompacted) - { - state.MessageGroups = [.. messageIndex.Groups]; - } + await this._compactionStrategy.CompactAsync(messageIndex, cancellationToken).ConfigureAwait(false); + + // Persist the index + state.MessageGroups.Clear(); + state.MessageGroups.AddRange(messageIndex.Groups); return new AIContext { Instructions = context.AIContext.Instructions, - Messages = wasCompacted ? messageIndex.GetIncludedMessages() : messageList, + Messages = messageIndex.GetIncludedMessages(), Tools = context.AIContext.Tools }; } From f40710f42410d32e540ee6edd1caf83e00ca4d5b Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Mon, 9 Mar 2026 16:21:46 -0700 Subject: [PATCH 46/80] Address comments (x2) --- .../Program.cs | 2 +- .../InMemoryChatHistoryProvider.cs | 19 ++++++++----------- .../Compaction/CompactionProvider.cs | 2 +- .../Compaction/MessageGroup.cs | 2 +- .../Compaction/MessageIndex.cs | 3 ++- 5 files changed, 13 insertions(+), 15 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 965f8fc3bb..cb8baea339 100644 --- a/dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/Program.cs +++ b/dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/Program.cs @@ -53,7 +53,7 @@ static string LookupPrice([Description("The product name to look up.")] string p // 4. Emergency: drop oldest groups until under the token budget new TruncationCompactionStrategy(CompactionTriggers.TokensExceed(0x8000))); -// Create the agent with a MessageCompactionContextProvider that uses the compaction pipeline. +// Create the agent with a CompactionProvider that uses the compaction pipeline. AIAgent agent = agentChatClient .AsBuilder() diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/InMemoryChatHistoryProvider.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/InMemoryChatHistoryProvider.cs index 7597328c83..46429b802c 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/InMemoryChatHistoryProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/InMemoryChatHistoryProvider.cs @@ -90,10 +90,10 @@ protected override async ValueTask> ProvideChatHistoryA { State state = this._sessionState.GetOrInitializeState(context.Session); - if (this.ReducerTriggerEvent is InMemoryChatHistoryProviderOptions.ChatReducerTriggerEvent.BeforeMessagesRetrieval) + if (this.ReducerTriggerEvent is InMemoryChatHistoryProviderOptions.ChatReducerTriggerEvent.BeforeMessagesRetrieval && this.ChatReducer is not null) { - // Apply pre-invocation compaction strategy if configured - await this.CompactMessagesAsync(state, cancellationToken).ConfigureAwait(false); + // Apply pre-retrieval compaction strategy if configured + await CompactMessagesAsync(this.ChatReducer, state, cancellationToken).ConfigureAwait(false); } return state.Messages; @@ -108,20 +108,17 @@ protected override async ValueTask StoreChatHistoryAsync(InvokedContext context, var allNewMessages = context.RequestMessages.Concat(context.ResponseMessages ?? []); state.Messages.AddRange(allNewMessages); - if (this.ReducerTriggerEvent is InMemoryChatHistoryProviderOptions.ChatReducerTriggerEvent.AfterMessageAdded) + if (this.ReducerTriggerEvent is InMemoryChatHistoryProviderOptions.ChatReducerTriggerEvent.AfterMessageAdded && this.ChatReducer is not null) { // Apply pre-write compaction strategy if configured - await this.CompactMessagesAsync(state, cancellationToken).ConfigureAwait(false); + await CompactMessagesAsync(this.ChatReducer, state, cancellationToken).ConfigureAwait(false); } } - private async Task CompactMessagesAsync(State state, CancellationToken cancellationToken = default) + private static async Task CompactMessagesAsync(IChatReducer reducer, State state, CancellationToken cancellationToken = default) { - if (this.ChatReducer is not null) - { - state.Messages = [.. await this.ChatReducer.ReduceAsync(state.Messages, cancellationToken).ConfigureAwait(false)]; - return; - } + state.Messages = [.. await reducer.ReduceAsync(state.Messages, cancellationToken).ConfigureAwait(false)]; + return; } /// diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionProvider.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionProvider.cs index 75dffa82e6..70a8ee3db6 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionProvider.cs @@ -123,7 +123,7 @@ public sealed class State /// /// Gets or sets the message index groups used for incremental compaction updates. /// - [JsonPropertyName("messages")] + [JsonPropertyName("messagegroups")] public List MessageGroups { get; set; } = []; } } diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/MessageGroup.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/MessageGroup.cs index 2fd8cc02fe..27724d9d63 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/MessageGroup.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/MessageGroup.cs @@ -89,7 +89,7 @@ internal MessageGroup(MessageGroupKind kind, IReadOnlyList messages /// /// 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 index - /// of 0 corresponds with any non-system message that preceeds the first user message, + /// of 0 corresponds with any non-system message that precedes the first user message, /// turn index 1 corresponds with the first user message and its subsequent non-user /// messages, and so on. /// diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/MessageIndex.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/MessageIndex.cs index cd41bb5ac2..86a88bc8dc 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/MessageIndex.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/MessageIndex.cs @@ -47,8 +47,9 @@ public sealed class MessageIndex /// An optional tokenizer retained for computing token counts when adding new groups. public MessageIndex(IList groups, Tokenizer? tokenizer = null) { - this.Groups = groups; this.Tokenizer = tokenizer; + this.Groups = groups; + this.ProcessedMessageCount = this.TotalMessageCount; // Restore turn counter from the last group that has a TurnIndex for (int index = groups.Count - 1; index >= 0; --index) From 5c406b809093d1a3751e7cb64873de4d95fd3ea1 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Mon, 9 Mar 2026 17:16:47 -0700 Subject: [PATCH 47/80] Direct compaction --- .../Compaction/CompactionProvider.cs | 27 ++++- .../Compaction/MessageGroup.cs | 8 +- .../Compaction/CompactionProviderTests.cs | 98 +++++++++++++++++++ 3 files changed, 125 insertions(+), 8 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionProvider.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionProvider.cs index 70a8ee3db6..f006564d13 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionProvider.cs @@ -33,7 +33,6 @@ public sealed class CompactionProvider : AIContextProvider { private readonly CompactionStrategy _compactionStrategy; private readonly ProviderSessionState _sessionState; - private IReadOnlyList? _stateKeys; /// /// Initializes a new instance of the class. @@ -46,14 +45,34 @@ public sealed class CompactionProvider : AIContextProvider public CompactionProvider(CompactionStrategy compactionStrategy, string? stateKey = null) { this._compactionStrategy = Throw.IfNull(compactionStrategy); + stateKey ??= $"{nameof(CompactionProvider)}:{Convert.ToBase64String(BitConverter.GetBytes(compactionStrategy.GetHashCode()))}"; + this.StateKeys = [stateKey]; this._sessionState = new ProviderSessionState( _ => new State(), - stateKey ?? $"{nameof(CompactionProvider)}:{Convert.ToBase64String(BitConverter.GetBytes(compactionStrategy.GetHashCode()))}", + stateKey, AgentJsonUtilities.DefaultOptions); } /// - public override IReadOnlyList StateKeys => this._stateKeys ??= [this._sessionState.StateKey]; + public override IReadOnlyList StateKeys { get; } + + /// + /// Applies compaction strategy to the provided message list and returns the compacted messages. + /// This can be used for ad-hoc compaction outside of the provider pipeline. + /// + /// The compaction strategy to apply before each invocation. + /// The messages to compact + /// The to monitor for cancellation requests. + /// + public static async Task> CompactAsync(CompactionStrategy compactionStrategy, IEnumerable messages, CancellationToken cancellationToken = default) + { + List messageList = messages as List ?? [.. messages]; + MessageIndex messageIndex = MessageIndex.Create(messageList); + + await compactionStrategy.CompactAsync(messageIndex, cancellationToken).ConfigureAwait(false); + + return messageIndex.GetIncludedMessages(); + } /// /// Applies the compaction strategy to the accumulated message list before forwarding it to the agent. @@ -118,7 +137,7 @@ protected override async ValueTask InvokingCoreAsync(InvokingContext /// /// Represents the persisted state of a stored in the . /// - public sealed class State + internal sealed class State { /// /// Gets or sets the message index groups used for incremental compaction updates. diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/MessageGroup.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/MessageGroup.cs index 27724d9d63..6e9a81567a 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/MessageGroup.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/MessageGroup.cs @@ -87,11 +87,11 @@ internal MessageGroup(MessageGroupKind kind, IReadOnlyList 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 index - /// of 0 corresponds with any non-system message that precedes the first user message, + /// Gets user turn index this group belongs to, or for groups + /// that precede the first user message (e.g., system messages). A turn index of 0 + /// corresponds with any non-system message that precedes the first user message, /// turn index 1 corresponds with the first user message and its subsequent non-user - /// messages, and so on. + /// messages, and so on... /// /// /// A turn starts with a group and includes all subsequent diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionProviderTests.cs index e2f1c2ecd5..86f6972963 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionProviderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionProviderTests.cs @@ -253,5 +253,103 @@ public async Task InvokingAsyncWithNonListEnumerableCreatesListCopyAsync() Assert.Equal("Hello", resultList[0].Text); } + [Fact] + public async Task CompactAsyncThrowsOnNullStrategyAsync() + { + List messages = [new ChatMessage(ChatRole.User, "Hello")]; + + await Assert.ThrowsAsync(() => CompactionProvider.CompactAsync(null!, messages)); + } + + [Fact] + public async Task CompactAsyncReturnsAllMessagesWhenTriggerDoesNotFireAsync() + { + // Arrange — trigger never fires → no compaction + TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(100000)); + List messages = + [ + new ChatMessage(ChatRole.User, "Q1"), + new ChatMessage(ChatRole.Assistant, "A1"), + new ChatMessage(ChatRole.User, "Q2"), + ]; + + // Act + IEnumerable result = await CompactionProvider.CompactAsync(strategy, messages); + + // Assert — all messages preserved + List resultList = [.. result]; + Assert.Equal(messages.Count, resultList.Count); + Assert.Equal("Q1", resultList[0].Text); + Assert.Equal("A1", resultList[1].Text); + Assert.Equal("Q2", resultList[2].Text); + } + + [Fact] + public async Task CompactAsyncReducesMessagesWhenTriggeredAsync() + { + // Arrange — strategy that always triggers and keeps only 1 group + TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreserved: 1); + List messages = + [ + new ChatMessage(ChatRole.User, "Q1"), + new ChatMessage(ChatRole.Assistant, "A1"), + new ChatMessage(ChatRole.User, "Q2"), + ]; + + // Act + IEnumerable result = await CompactionProvider.CompactAsync(strategy, messages); + + // Assert — compaction should have reduced the message count + List resultList = [.. result]; + Assert.True(resultList.Count < messages.Count); + } + + [Fact] + public async Task CompactAsyncHandlesEmptyMessageListAsync() + { + // Arrange + TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreserved: 1); + List messages = []; + + // Act + IEnumerable result = await CompactionProvider.CompactAsync(strategy, messages); + + // Assert + Assert.Empty(result); + } + + [Fact] + public async Task CompactAsyncWorksWithNonListEnumerableAsync() + { + // Arrange — IEnumerable (not a List) to exercise the list copy branch + TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(100000)); + IEnumerable messages = [new ChatMessage(ChatRole.User, "Hello")]; + + // Act + IEnumerable result = await CompactionProvider.CompactAsync(strategy, messages); + + // Assert + List resultList = [.. result]; + Assert.Single(resultList); + Assert.Equal("Hello", resultList[0].Text); + } + + [Fact] + public void CompactionStateAssignment() + { + // Arrange + CompactionProvider.State state = new(); + + // Assert + Assert.NotNull(state.MessageGroups); + Assert.Empty(state.MessageGroups); + + // Act + state.MessageGroups = [new MessageGroup(MessageGroupKind.User, [], 0, 0, 0)]; + + // Assert + Assert.Single(state.MessageGroups); + } + private sealed class TestAgentSession : AgentSession; } From b0138dca9e33a27b540d8953e2e5cc1ac4f52409 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Mon, 9 Mar 2026 17:30:31 -0700 Subject: [PATCH 48/80] Fix summarization content --- .../SummarizationCompactionStrategy.cs | 25 ++++--------------- .../SummarizationCompactionStrategyTests.cs | 8 ++++-- 2 files changed, 11 insertions(+), 22 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/SummarizationCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/SummarizationCompactionStrategy.cs index dee89b6398..2992ff5d6c 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/SummarizationCompactionStrategy.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/SummarizationCompactionStrategy.cs @@ -1,9 +1,8 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; -using System.Linq; -using System.Text; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.AI; @@ -121,7 +120,7 @@ protected override async ValueTask CompactCoreAsync(MessageIndex index, Ca } // Mark oldest non-system groups for summarization one at a time until the target is met - StringBuilder conversationText = new(); + List summarizationMessages = [new ChatMessage(ChatRole.System, this.SummarizationPrompt)]; int summarized = 0; int insertIndex = -1; @@ -138,15 +137,8 @@ protected override async ValueTask CompactCoreAsync(MessageIndex index, Ca insertIndex = i; } - // Build text representation of the group for summarization - foreach (ChatMessage message in group.Messages) - { - string text = message.Text; - if (!string.IsNullOrEmpty(text)) - { - conversationText.AppendLine($"{message.Role}: {text}"); - } - } + // Collect messages from this group for summarization + summarizationMessages.AddRange(group.Messages); group.IsExcluded = true; group.ExcludeReason = $"Summarized by {nameof(SummarizationCompactionStrategy)}"; @@ -161,14 +153,7 @@ protected override async ValueTask CompactCoreAsync(MessageIndex index, Ca // 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), - .. 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."), - ], + summarizationMessages, cancellationToken: cancellationToken).ConfigureAwait(false); string summaryText = string.IsNullOrWhiteSpace(response.Text) ? "[Summary unavailable]" : response.Text; diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/SummarizationCompactionStrategyTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/SummarizationCompactionStrategyTests.cs index 0bf225ae34..9d2f4b25b2 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/SummarizationCompactionStrategyTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/SummarizationCompactionStrategyTests.cs @@ -212,9 +212,13 @@ public async Task CompactAsyncUsesCustomPromptAsync() // Act await strategy.CompactAsync(index); - // Assert — the custom prompt should be the first message sent to the LLM + // Assert — the custom prompt should be the system message, followed by the original messages Assert.NotNull(capturedMessages); - Assert.Equal(CustomPrompt, capturedMessages![0].Text); + Assert.Equal(2, capturedMessages.Count); + Assert.Equal(ChatRole.System, capturedMessages![0].Role); + Assert.Equal(CustomPrompt, capturedMessages[0].Text); + Assert.Equal(ChatRole.User, capturedMessages[1].Role); + Assert.Equal("Q1", capturedMessages[1].Text); } [Fact] From 8179e2e1fb629abffba8d26bf2b56007f1a6aba4 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Mon, 9 Mar 2026 17:49:37 -0700 Subject: [PATCH 49/80] Argument check / fix count calculation --- .../Microsoft.Agents.AI/Compaction/CompactionProvider.cs | 3 +++ dotnet/src/Microsoft.Agents.AI/Compaction/MessageIndex.cs | 2 +- .../Compaction/ToolResultCompactionStrategy.cs | 6 +++++- .../Compaction/CompactionProviderTests.cs | 2 +- 4 files changed, 10 insertions(+), 3 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionProvider.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionProvider.cs index f006564d13..dec3c5d89c 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionProvider.cs @@ -66,6 +66,9 @@ public CompactionProvider(CompactionStrategy compactionStrategy, string? stateKe /// public static async Task> CompactAsync(CompactionStrategy compactionStrategy, IEnumerable messages, CancellationToken cancellationToken = default) { + Throw.IfNull(compactionStrategy); + Throw.IfNull(messages); + List messageList = messages as List ?? [.. messages]; MessageIndex messageIndex = MessageIndex.Create(messageList); diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/MessageIndex.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/MessageIndex.cs index 86a88bc8dc..cf74cf6304 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/MessageIndex.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/MessageIndex.cs @@ -49,7 +49,7 @@ public MessageIndex(IList groups, Tokenizer? tokenizer = null) { this.Tokenizer = tokenizer; this.Groups = groups; - this.ProcessedMessageCount = this.TotalMessageCount; + this.ProcessedMessageCount = this.Groups.Where(g => g.Kind != MessageGroupKind.Summary).Sum(g => g.MessageCount); // Restore turn counter from the last group that has a TurnIndex for (int index = groups.Count - 1; index >= 0; --index) diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/ToolResultCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/ToolResultCompactionStrategy.cs index b609d7e751..9d5f0d1dfb 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/ToolResultCompactionStrategy.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/ToolResultCompactionStrategy.cs @@ -134,7 +134,11 @@ protected override ValueTask CompactCoreAsync(MessageIndex index, Cancella group.ExcludeReason = $"Collapsed by {nameof(ToolResultCompactionStrategy)}"; string summary = $"[Tool calls: {string.Join(", ", toolNames)}]"; - index.InsertGroup(idx + 1, MessageGroupKind.AssistantText, [new ChatMessage(ChatRole.Assistant, summary)], group.TurnIndex); + + ChatMessage summaryMessage = new(ChatRole.Assistant, summary); + (summaryMessage.AdditionalProperties ??= [])[MessageGroup.SummaryPropertyKey] = true; + + index.InsertGroup(idx + 1, MessageGroupKind.Summary, [summaryMessage], group.TurnIndex); offset++; // Each insertion shifts subsequent indices by 1 compacted = true; diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionProviderTests.cs index 86f6972963..8344ee265d 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionProviderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionProviderTests.cs @@ -258,7 +258,7 @@ public async Task CompactAsyncThrowsOnNullStrategyAsync() { List messages = [new ChatMessage(ChatRole.User, "Hello")]; - await Assert.ThrowsAsync(() => CompactionProvider.CompactAsync(null!, messages)); + await Assert.ThrowsAsync(() => CompactionProvider.CompactAsync(null!, messages)); } [Fact] From 011995b0043a81b8c3dc56aeb308db0d68b71145 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Mon, 9 Mar 2026 19:53:34 -0700 Subject: [PATCH 50/80] Minor follow-up --- .../Microsoft.Agents.AI/Compaction/CompactionProvider.cs | 2 +- .../src/Microsoft.Agents.AI/Compaction/CompactionTrigger.cs | 2 +- .../Microsoft.Agents.AI/Compaction/CompactionTriggers.cs | 6 +++--- dotnet/src/Microsoft.Agents.AI/Compaction/MessageIndex.cs | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionProvider.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionProvider.cs index dec3c5d89c..b94cde4edd 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionProvider.cs @@ -113,7 +113,7 @@ protected override async ValueTask InvokingCoreAsync(InvokingContext if (state.MessageGroups.Count > 0) { // Update existing index with any new messages appended since the last call. - messageIndex = new(state.MessageGroups); + messageIndex = new([.. state.MessageGroups]); messageIndex.Update(messageList); } else diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionTrigger.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionTrigger.cs index dbbcc21325..ff8bde6008 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionTrigger.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionTrigger.cs @@ -10,6 +10,6 @@ namespace Microsoft.Agents.AI.Compaction; /// to determine when to trigger compaction and when the target compaction threshold has been met. /// /// An index over conversation messages that provides group, token, message, and turn metrics. -/// to the trigger that the condition has been met; otherwise . +/// to indicate the condition has been met; otherwise . [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 0ad65ae42a..18d83af06d 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionTriggers.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionTriggers.cs @@ -37,10 +37,10 @@ public static class CompactionTriggers /// /// Creates a trigger that fires when the included token count is below the specified maximum. /// - /// The token threshold. + /// The token threshold. /// A that evaluates included token count. - public static CompactionTrigger TokensBelow(int minTokens) => - index => index.IncludedTokenCount < minTokens; + 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/MessageIndex.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/MessageIndex.cs index cf74cf6304..cdc47a7362 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/MessageIndex.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/MessageIndex.cs @@ -275,7 +275,7 @@ public IEnumerable GetIncludedMessages() => /// /// Gets the number of user turns that have at least one non-excluded group. /// - public int IncludedTurnCount => this.Groups.Where(group => !group.IsExcluded && group.TurnIndex > 0).Select(group => group.TurnIndex).Distinct().Count(turnIndex => turnIndex is not null); + public int IncludedTurnCount => this.Groups.Where(group => !group.IsExcluded && group.TurnIndex is not null && group.TurnIndex > 0).Select(group => group.TurnIndex).Distinct().Count(); /// /// Gets the total number of groups across all included (non-excluded) groups that are not . From c68a7d35c952f43c9ec69537317e9e9f929e1b06 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Mon, 9 Mar 2026 21:04:00 -0700 Subject: [PATCH 51/80] Diagnostics --- .../ChatReducerCompactionStrategy.cs | 3 +- .../Compaction/CompactionLogMessages.cs | 101 ++++++++++++++++++ .../Compaction/CompactionProvider.cs | 45 ++++++-- .../Compaction/CompactionStrategy.cs | 74 ++++++++----- .../Compaction/CompactionTelemetry.cs | 45 ++++++++ .../Compaction/MessageIndex.cs | 2 - .../Compaction/PipelineCompactionStrategy.cs | 5 +- .../SlidingWindowCompactionStrategy.cs | 3 +- .../SummarizationCompactionStrategy.cs | 13 ++- .../ToolResultCompactionStrategy.cs | 3 +- .../TruncationCompactionStrategy.cs | 3 +- .../ChatReducerCompactionStrategyTests.cs | 10 +- .../Compaction/CompactionStrategyTests.cs | 3 +- .../PipelineCompactionStrategyTests.cs | 3 +- 14 files changed, 267 insertions(+), 46 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.AI/Compaction/CompactionLogMessages.cs create mode 100644 dotnet/src/Microsoft.Agents.AI/Compaction/CompactionTelemetry.cs diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/ChatReducerCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/ChatReducerCompactionStrategy.cs index 94b77a8771..f97088950e 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/ChatReducerCompactionStrategy.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/ChatReducerCompactionStrategy.cs @@ -5,6 +5,7 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; using Microsoft.Shared.DiagnosticIds; using Microsoft.Shared.Diagnostics; @@ -55,7 +56,7 @@ public ChatReducerCompactionStrategy(IChatReducer chatReducer, CompactionTrigger public IChatReducer ChatReducer { get; } /// - protected override async ValueTask CompactCoreAsync(MessageIndex index, CancellationToken cancellationToken) + protected override async ValueTask CompactCoreAsync(MessageIndex index, ILogger logger, CancellationToken cancellationToken) { // No need to short-circuit on empty conversations, this is handled by . List includedMessages = [.. index.GetIncludedMessages()]; diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionLogMessages.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionLogMessages.cs new file mode 100644 index 0000000000..f6461f7a58 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionLogMessages.cs @@ -0,0 +1,101 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Agents.AI.Compaction; + +#pragma warning disable SYSLIB1006 // Multiple logging methods cannot use the same event id within a class + +/// +/// Extensions for logging compaction diagnostics. +/// +/// +/// This extension uses the to +/// generate logging code at compile time to achieve optimized code. +/// +[ExcludeFromCodeCoverage] +internal static partial class CompactionLogMessages +{ + /// + /// Logs when compaction is skipped because the trigger condition was not met. + /// + [LoggerMessage( + Level = LogLevel.Trace, + Message = "Compaction skipped for {StrategyName}: trigger condition not met or insufficient groups.")] + public static partial void LogCompactionSkipped( + this ILogger logger, + string strategyName); + + /// + /// Logs compaction completion with before/after metrics. + /// + [LoggerMessage( + Level = LogLevel.Debug, + Message = "Compaction completed: {StrategyName} in {DurationMs}ms — Messages {BeforeMessages}→{AfterMessages}, Groups {BeforeGroups}→{AfterGroups}, Tokens {BeforeTokens}→{AfterTokens}")] + public static partial void LogCompactionCompleted( + this ILogger logger, + string strategyName, + long durationMs, + int beforeMessages, + int afterMessages, + int beforeGroups, + int afterGroups, + int beforeTokens, + int afterTokens); + + /// + /// Logs when the compaction provider skips compaction. + /// + [LoggerMessage( + Level = LogLevel.Trace, + Message = "CompactionProvider skipped: {Reason}.")] + public static partial void LogCompactionProviderSkipped( + this ILogger logger, + string reason); + + /// + /// Logs when the compaction provider begins applying a compaction strategy. + /// + [LoggerMessage( + Level = LogLevel.Debug, + Message = "CompactionProvider applying compaction to {MessageCount} messages using {StrategyName}.")] + public static partial void LogCompactionProviderApplying( + this ILogger logger, + int messageCount, + string strategyName); + + /// + /// Logs when the compaction provider has applied compaction with result metrics. + /// + [LoggerMessage( + Level = LogLevel.Debug, + Message = "CompactionProvider compaction applied: messages {BeforeMessages}→{AfterMessages}.")] + public static partial void LogCompactionProviderApplied( + this ILogger logger, + int beforeMessages, + int afterMessages); + + /// + /// Logs when a summarization LLM call is starting. + /// + [LoggerMessage( + Level = LogLevel.Debug, + Message = "Summarization starting for {GroupCount} groups ({MessageCount} messages) using {ChatClientType}.")] + public static partial void LogSummarizationStarting( + this ILogger logger, + int groupCount, + int messageCount, + string chatClientType); + + /// + /// Logs when a summarization LLM call has completed. + /// + [LoggerMessage( + Level = LogLevel.Debug, + Message = "Summarization completed: summary length {SummaryLength} characters, inserted at index {InsertIndex}.")] + public static partial void LogSummarizationCompleted( + this ILogger logger, + int summaryLength, + int insertIndex); +} diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionProvider.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionProvider.cs index b94cde4edd..63153b6197 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionProvider.cs @@ -2,11 +2,14 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Shared.DiagnosticIds; using Microsoft.Shared.Diagnostics; @@ -33,6 +36,7 @@ public sealed class CompactionProvider : AIContextProvider { private readonly CompactionStrategy _compactionStrategy; private readonly ProviderSessionState _sessionState; + private readonly ILoggerFactory? _loggerFactory; /// /// Initializes a new instance of the class. @@ -41,8 +45,12 @@ public sealed class CompactionProvider : AIContextProvider /// /// An optional key used to store the provider state in the . /// + /// + /// An optional used to create a logger for provider diagnostics. + /// When , logging is disabled. + /// /// is . - public CompactionProvider(CompactionStrategy compactionStrategy, string? stateKey = null) + public CompactionProvider(CompactionStrategy compactionStrategy, string? stateKey = null, ILoggerFactory? loggerFactory = null) { this._compactionStrategy = Throw.IfNull(compactionStrategy); stateKey ??= $"{nameof(CompactionProvider)}:{Convert.ToBase64String(BitConverter.GetBytes(compactionStrategy.GetHashCode()))}"; @@ -51,6 +59,7 @@ public CompactionProvider(CompactionStrategy compactionStrategy, string? stateKe _ => new State(), stateKey, AgentJsonUtilities.DefaultOptions); + this._loggerFactory = loggerFactory; } /// @@ -62,9 +71,10 @@ public CompactionProvider(CompactionStrategy compactionStrategy, string? stateKe /// /// The compaction strategy to apply before each invocation. /// The messages to compact + /// An optional for emitting compaction diagnostics. /// The to monitor for cancellation requests. /// - public static async Task> CompactAsync(CompactionStrategy compactionStrategy, IEnumerable messages, CancellationToken cancellationToken = default) + public static async Task> CompactAsync(CompactionStrategy compactionStrategy, IEnumerable messages, ILogger? logger = null, CancellationToken cancellationToken = default) { Throw.IfNull(compactionStrategy); Throw.IfNull(messages); @@ -72,7 +82,7 @@ public static async Task> CompactAsync(CompactionStrate List messageList = messages as List ?? [.. messages]; MessageIndex messageIndex = MessageIndex.Create(messageList); - await compactionStrategy.CompactAsync(messageIndex, cancellationToken).ConfigureAwait(false); + await compactionStrategy.CompactAsync(messageIndex, logger, cancellationToken).ConfigureAwait(false); return messageIndex.GetIncludedMessages(); } @@ -88,12 +98,17 @@ public static async Task> CompactAsync(CompactionStrate /// protected override async ValueTask InvokingCoreAsync(InvokingContext context, CancellationToken cancellationToken = default) { + using Activity? activity = CompactionTelemetry.ActivitySource.StartActivity(CompactionTelemetry.ActivityNames.CompactionProviderInvoke); + + ILoggerFactory loggerFactory = this.GetLoggerFactory(context.Agent); + ILogger logger = loggerFactory.CreateLogger(); + AgentSession? session = context.Session; IEnumerable? allMessages = context.AIContext.Messages; if (session is null || allMessages is null) { - // No session available or no messages — pass through unchanged. + logger.LogCompactionProviderSkipped("no session or no messages"); return context.AIContext; } @@ -101,7 +116,7 @@ protected override async ValueTask InvokingCoreAsync(InvokingContext if (chatClientSession is not null && !string.IsNullOrWhiteSpace(chatClientSession.ConversationId)) { - // Session is managed by remote service + logger.LogCompactionProviderSkipped("session managed by remote service"); return context.AIContext; } @@ -122,8 +137,21 @@ protected override async ValueTask InvokingCoreAsync(InvokingContext messageIndex = MessageIndex.Create(messageList); } + string strategyName = this._compactionStrategy.GetType().Name; + int beforeMessages = messageIndex.IncludedMessageCount; + logger.LogCompactionProviderApplying(beforeMessages, strategyName); + // Apply compaction - await this._compactionStrategy.CompactAsync(messageIndex, cancellationToken).ConfigureAwait(false); + await this._compactionStrategy.CompactAsync( + messageIndex, + loggerFactory.CreateLogger(this._compactionStrategy.GetType()), + cancellationToken).ConfigureAwait(false); + + int afterMessages = messageIndex.IncludedMessageCount; + if (afterMessages < beforeMessages) + { + logger.LogCompactionProviderApplied(beforeMessages, afterMessages); + } // Persist the index state.MessageGroups.Clear(); @@ -137,6 +165,11 @@ protected override async ValueTask InvokingCoreAsync(InvokingContext }; } + private ILoggerFactory GetLoggerFactory(AIAgent agent) => + this._loggerFactory ?? + agent.GetService()?.GetService() ?? + NullLoggerFactory.Instance; + /// /// Represents the persisted state of a stored in the . /// diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionStrategy.cs index 2902522da8..480b104fe2 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionStrategy.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionStrategy.cs @@ -4,6 +4,8 @@ using System.Diagnostics.CodeAnalysis; using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Shared.DiagnosticIds; using Microsoft.Shared.Diagnostics; @@ -75,56 +77,80 @@ protected CompactionStrategy(CompactionTrigger trigger, CompactionTrigger? targe /// protected CompactionTrigger Target { get; } + /// + /// 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. Implementations should use + /// to determine when to stop compacting incrementally. + /// + /// The message index to compact. The strategy mutates this collection in place. + /// The for emitting compaction diagnostics. + /// The to monitor for cancellation requests. + /// A task whose result is if any compaction was performed, otherwise. + protected abstract ValueTask CompactCoreAsync(MessageIndex index, ILogger logger, CancellationToken cancellationToken); + /// /// Evaluates the and, when it fires, delegates to /// and reports compaction metrics. /// /// The message index to compact. The strategy mutates this collection in place. + /// An optional for emitting compaction diagnostics. When , logging is disabled. /// The to monitor for cancellation requests. /// A task representing the asynchronous operation. The task result is if compaction occurred, otherwise. - public async ValueTask CompactAsync(MessageIndex index, CancellationToken cancellationToken = default) + public async ValueTask CompactAsync(MessageIndex index, ILogger? logger = null, CancellationToken cancellationToken = default) { + string strategyName = this.GetType().Name; + logger ??= NullLogger.Instance; + + using Activity? activity = CompactionTelemetry.ActivitySource.StartActivity(CompactionTelemetry.ActivityNames.Compact); + activity?.SetTag(CompactionTelemetry.Tags.Strategy, strategyName); + if (index.IncludedNonSystemGroupCount <= 1 || !this.Trigger(index)) { + activity?.SetTag(CompactionTelemetry.Tags.Triggered, false); + logger.LogCompactionSkipped(strategyName); return false; } + activity?.SetTag(CompactionTelemetry.Tags.Triggered, true); + int beforeTokens = index.IncludedTokenCount; int beforeGroups = index.IncludedGroupCount; int beforeMessages = index.IncludedMessageCount; Stopwatch stopwatch = Stopwatch.StartNew(); - bool compacted = await this.CompactCoreAsync(index, cancellationToken).ConfigureAwait(false); + bool compacted = await this.CompactCoreAsync(index, logger, cancellationToken).ConfigureAwait(false); stopwatch.Stop(); + activity?.SetTag(CompactionTelemetry.Tags.Compacted, compacted); + if (compacted) { - Debug.WriteLine( - $""" - COMPACTION: {this.GetType().Name} - Duration {stopwatch.ElapsedMilliseconds}ms - Messages {beforeMessages} => {index.IncludedMessageCount} - Groups {beforeGroups} => {index.IncludedGroupCount} - Tokens {beforeTokens} => {index.IncludedTokenCount} - """); + activity? + .SetTag(CompactionTelemetry.Tags.BeforeTokens, beforeTokens) + .SetTag(CompactionTelemetry.Tags.AfterTokens, index.IncludedTokenCount) + .SetTag(CompactionTelemetry.Tags.BeforeMessages, beforeMessages) + .SetTag(CompactionTelemetry.Tags.AfterMessages, index.IncludedMessageCount) + .SetTag(CompactionTelemetry.Tags.BeforeGroups, beforeGroups) + .SetTag(CompactionTelemetry.Tags.AfterGroups, index.IncludedGroupCount) + .SetTag(CompactionTelemetry.Tags.DurationMs, stopwatch.ElapsedMilliseconds); + + logger.LogCompactionCompleted( + strategyName, + stopwatch.ElapsedMilliseconds, + beforeMessages, + index.IncludedMessageCount, + beforeGroups, + index.IncludedGroupCount, + beforeTokens, + index.IncludedTokenCount); } return compacted; } - - /// - /// 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. 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. - /// A task whose result is if any compaction was performed, otherwise. - protected abstract ValueTask CompactCoreAsync(MessageIndex index, CancellationToken cancellationToken); } diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionTelemetry.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionTelemetry.cs new file mode 100644 index 0000000000..11b37dfa82 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionTelemetry.cs @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics; + +namespace Microsoft.Agents.AI.Compaction; + +/// +/// Provides shared telemetry infrastructure for compaction operations. +/// +internal static class CompactionTelemetry +{ + /// + /// The used to create activities for compaction operations. + /// + public static readonly ActivitySource ActivitySource = new(OpenTelemetryConsts.DefaultSourceName); + + /// + /// Activity names used by compaction tracing. + /// + public static class ActivityNames + { + public const string Compact = "compaction.compact"; + public const string CompactionProviderInvoke = "compaction.provider.invoke"; + public const string Summarize = "compaction.summarize"; + } + + /// + /// Tag names used on compaction activities. + /// + public static class Tags + { + public const string Strategy = "compaction.strategy"; + public const string Triggered = "compaction.triggered"; + public const string Compacted = "compaction.compacted"; + public const string BeforeTokens = "compaction.before.tokens"; + public const string AfterTokens = "compaction.after.tokens"; + public const string BeforeMessages = "compaction.before.messages"; + public const string AfterMessages = "compaction.after.messages"; + public const string BeforeGroups = "compaction.before.groups"; + public const string AfterGroups = "compaction.after.groups"; + public const string DurationMs = "compaction.duration_ms"; + public const string GroupsSummarized = "compaction.groups_summarized"; + public const string SummaryLength = "compaction.summary_length"; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/MessageIndex.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/MessageIndex.cs index cdc47a7362..8110fae9a6 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/MessageIndex.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/MessageIndex.cs @@ -1,7 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. using System.Collections.Generic; -using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Text; @@ -83,7 +82,6 @@ public MessageIndex(IList groups, Tokenizer? tokenizer = null) /// internal static MessageIndex Create(IList messages, Tokenizer? tokenizer = null) { - Debug.WriteLine($"COMPACTION: Creating index x{messages.Count} messages"); MessageIndex instance = new([], tokenizer); instance.AppendFromMessages(messages, 0); return instance; diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/PipelineCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/PipelineCompactionStrategy.cs index e49b8f2217..c3edbfb891 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/PipelineCompactionStrategy.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/PipelineCompactionStrategy.cs @@ -4,6 +4,7 @@ using System.Diagnostics.CodeAnalysis; using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.Logging; using Microsoft.Shared.DiagnosticIds; using Microsoft.Shared.Diagnostics; @@ -43,13 +44,13 @@ public PipelineCompactionStrategy(params IEnumerable strateg public IReadOnlyList Strategies { get; } /// - protected override async ValueTask CompactCoreAsync(MessageIndex index, CancellationToken cancellationToken) + protected override async ValueTask CompactCoreAsync(MessageIndex index, ILogger logger, CancellationToken cancellationToken) { bool anyCompacted = false; foreach (CompactionStrategy strategy in this.Strategies) { - bool compacted = await strategy.CompactAsync(index, cancellationToken).ConfigureAwait(false); + bool compacted = await strategy.CompactAsync(index, logger, cancellationToken).ConfigureAwait(false); if (compacted) { diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/SlidingWindowCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/SlidingWindowCompactionStrategy.cs index 6bedbbad2c..3d3b25c5fb 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/SlidingWindowCompactionStrategy.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/SlidingWindowCompactionStrategy.cs @@ -6,6 +6,7 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.Logging; using Microsoft.Shared.DiagnosticIds; namespace Microsoft.Agents.AI.Compaction; @@ -66,7 +67,7 @@ public SlidingWindowCompactionStrategy(CompactionTrigger trigger, int minimumPre public int MinimumPreserved { get; } /// - protected override ValueTask CompactCoreAsync(MessageIndex index, CancellationToken cancellationToken) + protected override ValueTask CompactCoreAsync(MessageIndex index, ILogger logger, CancellationToken cancellationToken) { // Identify protected groups: the N most-recent non-system, non-excluded groups int[] nonSystemIncludedIndices = diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/SummarizationCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/SummarizationCompactionStrategy.cs index 2992ff5d6c..7de30fab41 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/SummarizationCompactionStrategy.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/SummarizationCompactionStrategy.cs @@ -2,10 +2,12 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; using Microsoft.Shared.DiagnosticIds; using Microsoft.Shared.Diagnostics; @@ -98,7 +100,7 @@ public SummarizationCompactionStrategy( public string SummarizationPrompt { get; } /// - protected override async ValueTask CompactCoreAsync(MessageIndex index, CancellationToken cancellationToken) + protected override async ValueTask CompactCoreAsync(MessageIndex index, ILogger logger, CancellationToken cancellationToken) { // Count non-system, non-excluded groups to determine which are protected int nonSystemIncludedCount = 0; @@ -152,18 +154,27 @@ protected override async ValueTask CompactCoreAsync(MessageIndex index, Ca } // Generate summary using the chat client (single LLM call for all marked groups) + logger.LogSummarizationStarting(summarized, summarizationMessages.Count - 1, this.ChatClient.GetType().Name); + + using Activity? summarizeActivity = CompactionTelemetry.ActivitySource.StartActivity(CompactionTelemetry.ActivityNames.Summarize); + summarizeActivity?.SetTag(CompactionTelemetry.Tags.GroupsSummarized, summarized); + ChatResponse response = await this.ChatClient.GetResponseAsync( summarizationMessages, cancellationToken: cancellationToken).ConfigureAwait(false); string summaryText = string.IsNullOrWhiteSpace(response.Text) ? "[Summary unavailable]" : response.Text; + summarizeActivity?.SetTag(CompactionTelemetry.Tags.SummaryLength, summaryText.Length); + // Insert a summary group at the position of the first summarized group ChatMessage summaryMessage = new(ChatRole.Assistant, $"[Summary]\n{summaryText}"); (summaryMessage.AdditionalProperties ??= [])[MessageGroup.SummaryPropertyKey] = true; index.InsertGroup(insertIndex, MessageGroupKind.Summary, [summaryMessage]); + logger.LogSummarizationCompleted(summaryText.Length, insertIndex); + return true; } } diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/ToolResultCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/ToolResultCompactionStrategy.cs index 9d5f0d1dfb..4d4d6c0fce 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/ToolResultCompactionStrategy.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/ToolResultCompactionStrategy.cs @@ -6,6 +6,7 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; using Microsoft.Shared.DiagnosticIds; namespace Microsoft.Agents.AI.Compaction; @@ -68,7 +69,7 @@ public ToolResultCompactionStrategy(CompactionTrigger trigger, int minimumPreser public int MinimumPreserved { get; } /// - protected override ValueTask CompactCoreAsync(MessageIndex index, CancellationToken cancellationToken) + protected override ValueTask CompactCoreAsync(MessageIndex index, ILogger logger, CancellationToken cancellationToken) { // Identify protected groups: the N most-recent non-system, non-excluded groups List nonSystemIncludedIndices = []; diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/TruncationCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/TruncationCompactionStrategy.cs index 81670560b2..bb13ee7773 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/TruncationCompactionStrategy.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/TruncationCompactionStrategy.cs @@ -3,6 +3,7 @@ using System.Diagnostics.CodeAnalysis; using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.Logging; using Microsoft.Shared.DiagnosticIds; namespace Microsoft.Agents.AI.Compaction; @@ -62,7 +63,7 @@ public TruncationCompactionStrategy(CompactionTrigger trigger, int minimumPreser public int MinimumPreserved { get; } /// - protected override ValueTask CompactCoreAsync(MessageIndex index, CancellationToken cancellationToken) + protected override ValueTask CompactCoreAsync(MessageIndex index, ILogger logger, CancellationToken cancellationToken) { // Count removable (non-system, non-excluded) groups int removableCount = 0; diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/ChatReducerCompactionStrategyTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/ChatReducerCompactionStrategyTests.cs index 6d4437d97a..07e7dae96e 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/ChatReducerCompactionStrategyTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/ChatReducerCompactionStrategyTests.cs @@ -205,11 +205,11 @@ public async Task CompactAsyncExposesReducerPropertyAsync() public async Task CompactAsyncPassesCancellationTokenToReducerAsync() { // Arrange - using CancellationTokenSource cts = new(); + using CancellationTokenSource cancellationSource = new(); CancellationToken capturedToken = default; - TestChatReducer reducer = new((messages, ct) => + TestChatReducer reducer = new((messages, cancellationToken) => { - capturedToken = ct; + capturedToken = cancellationToken; return Task.FromResult>(messages.Skip(messages.Count() - 1).ToList()); }); @@ -221,10 +221,10 @@ public async Task CompactAsyncPassesCancellationTokenToReducerAsync() ]); // Act - await strategy.CompactAsync(index, cts.Token); + await strategy.CompactAsync(index, logger: null, cancellationSource.Token); // Assert - Assert.Equal(cts.Token, capturedToken); + Assert.Equal(cancellationSource.Token, capturedToken); } /// diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionStrategyTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionStrategyTests.cs index 8c6ee9e0ae..40d9715609 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionStrategyTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionStrategyTests.cs @@ -5,6 +5,7 @@ using System.Threading.Tasks; using Microsoft.Agents.AI.Compaction; using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; namespace Microsoft.Agents.AI.UnitTests.Compaction; @@ -225,7 +226,7 @@ public TestStrategy( /// public bool InvokeTarget(MessageIndex index) => this.Target(index); - protected override ValueTask CompactCoreAsync(MessageIndex index, CancellationToken cancellationToken) + protected override ValueTask CompactCoreAsync(MessageIndex index, ILogger logger, CancellationToken cancellationToken) { this.ApplyCallCount++; bool result = this._applyFunc?.Invoke(index) ?? false; diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/PipelineCompactionStrategyTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/PipelineCompactionStrategyTests.cs index 9c57db663c..a648085e65 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/PipelineCompactionStrategyTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/PipelineCompactionStrategyTests.cs @@ -6,6 +6,7 @@ using System.Threading.Tasks; using Microsoft.Agents.AI.Compaction; using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; namespace Microsoft.Agents.AI.UnitTests.Compaction; @@ -198,7 +199,7 @@ public TestCompactionStrategy(Func applyFunc) public int ApplyCallCount { get; private set; } - protected override ValueTask CompactCoreAsync(MessageIndex index, CancellationToken cancellationToken) + protected override ValueTask CompactCoreAsync(MessageIndex index, ILogger logger, CancellationToken cancellationToken) { this.ApplyCallCount++; return new(this._applyFunc(index)); From c010d7028d3f97e2efcbed5ee835c8b00099e820 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Mon, 9 Mar 2026 21:43:21 -0700 Subject: [PATCH 52/80] Minor updates --- .../InMemoryChatHistoryProvider.cs | 4 ++-- .../Microsoft.Agents.AI/Compaction/CompactionProvider.cs | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/InMemoryChatHistoryProvider.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/InMemoryChatHistoryProvider.cs index 46429b802c..46d8a7d1e3 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/InMemoryChatHistoryProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/InMemoryChatHistoryProvider.cs @@ -92,7 +92,7 @@ protected override async ValueTask> ProvideChatHistoryA if (this.ReducerTriggerEvent is InMemoryChatHistoryProviderOptions.ChatReducerTriggerEvent.BeforeMessagesRetrieval && this.ChatReducer is not null) { - // Apply pre-retrieval compaction strategy if configured + // Apply pre-retrieval reduction if configured await CompactMessagesAsync(this.ChatReducer, state, cancellationToken).ConfigureAwait(false); } @@ -110,7 +110,7 @@ protected override async ValueTask StoreChatHistoryAsync(InvokedContext context, if (this.ReducerTriggerEvent is InMemoryChatHistoryProviderOptions.ChatReducerTriggerEvent.AfterMessageAdded && this.ChatReducer is not null) { - // Apply pre-write compaction strategy if configured + // Apply pre-write reduction strategy if configured await CompactMessagesAsync(this.ChatReducer, state, cancellationToken).ConfigureAwait(false); } } diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionProvider.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionProvider.cs index 63153b6197..14aba7257a 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionProvider.cs @@ -16,7 +16,7 @@ namespace Microsoft.Agents.AI.Compaction; /// -/// A that applies a to compact +/// A that applies a to compact /// the message list before each agent invocation. /// /// @@ -73,7 +73,7 @@ public CompactionProvider(CompactionStrategy compactionStrategy, string? stateKe /// The messages to compact /// An optional for emitting compaction diagnostics. /// The to monitor for cancellation requests. - /// + /// An enumeration of the compacted instances. public static async Task> CompactAsync(CompactionStrategy compactionStrategy, IEnumerable messages, ILogger? logger = null, CancellationToken cancellationToken = default) { Throw.IfNull(compactionStrategy); @@ -94,7 +94,7 @@ public static async Task> CompactAsync(CompactionStrate /// The to monitor for cancellation requests. /// /// A task that represents the asynchronous operation. The task result contains an - /// with the compacted message list. If no compaction was needed, the original context is returned unchanged. + /// with the compacted message list. /// protected override async ValueTask InvokingCoreAsync(InvokingContext context, CancellationToken cancellationToken = default) { From 6df1e2363bfce8b57e72a68fc429d447947d77fd Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Mon, 9 Mar 2026 21:51:57 -0700 Subject: [PATCH 53/80] Fix state test --- .../Compaction/ChatMessageContentEquality.cs | 158 ++++++ .../Compaction/MessageIndex.cs | 116 ++-- .../ChatMessageContentEqualityTests.cs | 518 ++++++++++++++++++ .../Compaction/MessageIndexTests.cs | 55 +- 4 files changed, 810 insertions(+), 37 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.AI/Compaction/ChatMessageContentEquality.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/ChatMessageContentEqualityTests.cs diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/ChatMessageContentEquality.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/ChatMessageContentEquality.cs new file mode 100644 index 0000000000..8225490535 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/ChatMessageContentEquality.cs @@ -0,0 +1,158 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Compaction; + +/// +/// Content-based equality comparison for instances. +/// +internal static class ChatMessageContentEquality +{ + /// + /// Determines whether two instances represent the same message by content. + /// + /// + /// When both messages define a , identity is determined solely + /// by that identifier. Otherwise, the comparison falls through to , + /// , and each item in . + /// + internal static bool ContentEquals(this ChatMessage? message, ChatMessage? other) + { + if (ReferenceEquals(message, other)) + { + return true; + } + + if (message is null || other is null) + { + return false; + } + + // A matching MessageId is sufficient. + if (message.MessageId is not null && other.MessageId is not null) + { + return string.Equals(message.MessageId, other.MessageId, StringComparison.Ordinal); + } + + if (message.Role != other.Role) + { + return false; + } + + if (!string.Equals(message.AuthorName, other.AuthorName, StringComparison.Ordinal)) + { + return false; + } + + return ContentsEqual(message.Contents, other.Contents); + } + + private static bool ContentsEqual(IList left, IList right) + { + if (left.Count != right.Count) + { + return false; + } + + for (int i = 0; i < left.Count; i++) + { + if (!ContentItemEquals(left[i], right[i])) + { + return false; + } + } + + return true; + } + + private static bool ContentItemEquals(AIContent left, AIContent right) + { + if (ReferenceEquals(left, right)) + { + return true; + } + + if (left.GetType() != right.GetType()) + { + return false; + } + + return (left, right) switch + { + (TextContent a, TextContent b) => TextContentEquals(a, b), + (TextReasoningContent a, TextReasoningContent b) => TextReasoningContentEquals(a, b), + (DataContent a, DataContent b) => DataContentEquals(a, b), + (UriContent a, UriContent b) => UriContentEquals(a, b), + (ErrorContent a, ErrorContent b) => ErrorContentEquals(a, b), + (FunctionCallContent a, FunctionCallContent b) => FunctionCallContentEquals(a, b), + (FunctionResultContent a, FunctionResultContent b) => FunctionResultContentEquals(a, b), + (HostedFileContent a, HostedFileContent b) => HostedFileContentEquals(a, b), + (AIContent a, AIContent b) => a.GetType() == b.GetType(), + }; + } + + private static bool TextContentEquals(TextContent a, TextContent b) => + string.Equals(a.Text, b.Text, StringComparison.Ordinal); + + private static bool TextReasoningContentEquals(TextReasoningContent a, TextReasoningContent b) => + string.Equals(a.Text, b.Text, StringComparison.Ordinal) && + string.Equals(a.ProtectedData, b.ProtectedData, StringComparison.Ordinal); + + private static bool DataContentEquals(DataContent a, DataContent b) => + string.Equals(a.MediaType, b.MediaType, StringComparison.Ordinal) && + string.Equals(a.Name, b.Name, StringComparison.Ordinal) && + a.Data.Span.SequenceEqual(b.Data.Span); + + private static bool UriContentEquals(UriContent a, UriContent b) => + Equals(a.Uri, b.Uri) && + string.Equals(a.MediaType, b.MediaType, StringComparison.Ordinal); + + private static bool ErrorContentEquals(ErrorContent a, ErrorContent b) => + string.Equals(a.Message, b.Message, StringComparison.Ordinal) && + string.Equals(a.ErrorCode, b.ErrorCode, StringComparison.Ordinal); + + private static bool FunctionCallContentEquals(FunctionCallContent a, FunctionCallContent b) => + string.Equals(a.CallId, b.CallId, StringComparison.Ordinal) && + string.Equals(a.Name, b.Name, StringComparison.Ordinal) && + ArgumentsEqual(a.Arguments, b.Arguments); + + private static bool FunctionResultContentEquals(FunctionResultContent a, FunctionResultContent b) => + string.Equals(a.CallId, b.CallId, StringComparison.Ordinal) && + Equals(a.Result, b.Result); + + private static bool ArgumentsEqual(IDictionary? left, IDictionary? right) + { + if (ReferenceEquals(left, right)) + { + return true; + } + + if (left is null || right is null) + { + return false; + } + + if (left.Count != right.Count) + { + return false; + } + + foreach (KeyValuePair entry in left) + { + if (!right.TryGetValue(entry.Key, out object? value) || !Equals(entry.Value, value)) + { + return false; + } + } + + return true; + } + + private static bool HostedFileContentEquals(HostedFileContent a, HostedFileContent b) => + string.Equals(a.FileId, b.FileId, StringComparison.Ordinal) && + string.Equals(a.MediaType, b.MediaType, StringComparison.Ordinal) && + string.Equals(a.Name, b.Name, StringComparison.Ordinal); +} diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/MessageIndex.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/MessageIndex.cs index cdc47a7362..c0dc0f27c2 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/MessageIndex.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/MessageIndex.cs @@ -24,6 +24,7 @@ namespace Microsoft.Agents.AI.Compaction; public sealed class MessageIndex { private int _currentTurn; + private ChatMessage? _lastProcessedMessage; /// /// Gets the list of message groups in this collection. @@ -35,11 +36,6 @@ public sealed class MessageIndex /// public Tokenizer? Tokenizer { get; } - /// - /// Gets the number of raw messages that have been processed into groups. - /// - public int ProcessedMessageCount { get; private set; } - /// /// Initializes a new instance of the class with the specified groups. /// @@ -49,15 +45,25 @@ public MessageIndex(IList groups, Tokenizer? tokenizer = null) { this.Tokenizer = tokenizer; this.Groups = groups; - this.ProcessedMessageCount = this.Groups.Where(g => g.Kind != MessageGroupKind.Summary).Sum(g => g.MessageCount); - // Restore turn counter from the last group that has a TurnIndex + // Restore turn counter and last processed message from the groups for (int index = groups.Count - 1; index >= 0; --index) { + if (this._lastProcessedMessage is null && this.Groups[index].Kind != MessageGroupKind.Summary) + { + IReadOnlyList groupMessages = this.Groups[index].Messages; + this._lastProcessedMessage = groupMessages[^1]; + } + if (this.Groups[index].TurnIndex.HasValue) { this._currentTurn = this.Groups[index].TurnIndex!.Value; - break; + + // Both values restored — no need to keep scanning + if (this._lastProcessedMessage is not null) + { + break; + } } } } @@ -98,40 +104,75 @@ internal static MessageIndex Create(IList messages, Tokenizer? toke /// /// /// - /// 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. + /// Uses reference equality on the last processed message to detect changes. If the last + /// processed message is found within , only the messages + /// after that position are processed and appended as new groups. Existing groups and their + /// compaction state (exclusions) are preserved. /// /// - /// 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 last processed message is not found (e.group., the message list was replaced entirely + /// or a sliding window shifted past it), all groups are cleared and rebuilt from scratch. /// /// - /// If the message count equals , no work is performed. + /// If the last message in is the same reference as the last + /// processed message, no work is performed. /// /// internal void Update(IList allMessages) { - if (allMessages.Count == this.ProcessedMessageCount) + if (allMessages.Count == 0) + { + this.Groups.Clear(); + this._currentTurn = 0; + this._lastProcessedMessage = null; + return; + } + + // If the last message is unchanged and the list hasn't shrunk, there is nothing new to process. + if (this._lastProcessedMessage is not null && + allMessages.Count >= this.RawMessageCount && + allMessages[allMessages.Count - 1].ContentEquals(this._lastProcessedMessage)) { - return; // No new messages + return; } - if (allMessages.Count < this.ProcessedMessageCount) + // Walk backwards to locate where we left off. + int foundIndex = -1; + if (this._lastProcessedMessage is not null) { - // Message list shrank (e.g., after storage compaction). Rebuild from scratch. - this.ProcessedMessageCount = 0; + for (int i = allMessages.Count - 1; i >= 0; --i) + { + if (allMessages[i].ContentEquals(this._lastProcessedMessage)) + { + foundIndex = i; + break; + } + } + } + + if (foundIndex < 0) + { + // Last processed message not found — total rebuild. + this.Groups.Clear(); + this._currentTurn = 0; + this.AppendFromMessages(allMessages, 0); + return; } - if (this.ProcessedMessageCount == 0) + // Guard against a sliding window that removed messages from the front: + // the number of messages up to (and including) the found position must + // match the number of messages already represented by existing groups. + if (foundIndex + 1 < this.RawMessageCount) { - // First update on a manually constructed instance — clear any pre-existing groups + // Front of the message list was trimmed — rebuild. this.Groups.Clear(); this._currentTurn = 0; + this.AppendFromMessages(allMessages, 0); + return; } - // Process only the delta messages - this.AppendFromMessages(allMessages, this.ProcessedMessageCount); + // Process only the delta messages. + this.AppendFromMessages(allMessages, foundIndex + 1); } private void AppendFromMessages(IList messages, int startIndex) @@ -180,7 +221,10 @@ private void AppendFromMessages(IList messages, int startIndex) } } - this.ProcessedMessageCount = messages.Count; + if (messages.Count > 0) + { + this._lastProcessedMessage = messages[^1]; + } } /// @@ -235,37 +279,37 @@ public IEnumerable GetIncludedMessages() => /// /// Gets the total number of messages across all groups, including excluded ones. /// - public int TotalMessageCount => this.Groups.Sum(g => g.MessageCount); + public int TotalMessageCount => this.Groups.Sum(group => group.MessageCount); /// /// Gets the total UTF-8 byte count across all groups, including excluded ones. /// - public int TotalByteCount => this.Groups.Sum(g => g.ByteCount); + public int TotalByteCount => this.Groups.Sum(group => group.ByteCount); /// /// Gets the total token count across all groups, including excluded ones. /// - public int TotalTokenCount => this.Groups.Sum(g => g.TokenCount); + public int TotalTokenCount => this.Groups.Sum(group => group.TokenCount); /// /// Gets the total number of groups that are not excluded. /// - public int IncludedGroupCount => this.Groups.Count(g => !g.IsExcluded); + public int IncludedGroupCount => this.Groups.Count(group => !group.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); + public int IncludedMessageCount => this.Groups.Where(group => !group.IsExcluded).Sum(group => group.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); + public int IncludedByteCount => this.Groups.Where(group => !group.IsExcluded).Sum(group => group.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); + public int IncludedTokenCount => this.Groups.Where(group => !group.IsExcluded).Sum(group => group.TokenCount); /// /// Gets the total number of user turns across all groups (including those with excluded groups). @@ -280,15 +324,19 @@ public IEnumerable GetIncludedMessages() => /// /// Gets the total number of groups across all included (non-excluded) groups that are not . /// - public int IncludedNonSystemGroupCount => this.Groups.Count(g => !g.IsExcluded && g.Kind != MessageGroupKind.System); + public int IncludedNonSystemGroupCount => this.Groups.Count(group => !group.IsExcluded && group.Kind != MessageGroupKind.System); + + /// + /// Gets the total number of original messages (that are not summaries). + /// + public int RawMessageCount => this.Groups.Where(group => group.Kind != MessageGroupKind.Summary).Sum(group => group.MessageCount); /// /// 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); + public IEnumerable GetTurnGroups(int turnIndex) => this.Groups.Where(group => group.TurnIndex == turnIndex); /// /// Computes the UTF-8 byte count for a set of messages. diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/ChatMessageContentEqualityTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/ChatMessageContentEqualityTests.cs new file mode 100644 index 0000000000..0ec84f3cb3 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/ChatMessageContentEqualityTests.cs @@ -0,0 +1,518 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Text; +using Microsoft.Agents.AI.Compaction; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.UnitTests.Compaction; + +/// +/// Contains tests for the extension methods. +/// +public class ChatMessageContentEqualityTests +{ + #region Null and reference handling + + [Fact] + public void BothNullReturnsTrue() + { + ChatMessage? a = null; + ChatMessage? b = null; + + Assert.True(a.ContentEquals(b)); + } + + [Fact] + public void LeftNullReturnsFalse() + { + ChatMessage? a = null; + ChatMessage b = new(ChatRole.User, "Hello"); + + Assert.False(a.ContentEquals(b)); + } + + [Fact] + public void RightNullReturnsFalse() + { + ChatMessage a = new(ChatRole.User, "Hello"); + ChatMessage? b = null; + + Assert.False(a.ContentEquals(b)); + } + + [Fact] + public void SameReferenceReturnsTrue() + { + ChatMessage a = new(ChatRole.User, "Hello"); + + Assert.True(a.ContentEquals(a)); + } + + #endregion + + #region MessageId shortcut + + [Fact] + public void MatchingMessageIdReturnsTrue() + { + ChatMessage a = new(ChatRole.User, "Hello") { MessageId = "msg-1" }; + ChatMessage b = new(ChatRole.User, "Hello") { MessageId = "msg-1" }; + + Assert.True(a.ContentEquals(b)); + } + + [Fact] + public void MatchingMessageIdSufficientDespiteDifferentContent() + { + ChatMessage a = new(ChatRole.User, "Hello") { MessageId = "msg-1" }; + ChatMessage b = new(ChatRole.Assistant, "Goodbye") { MessageId = "msg-1" }; + + Assert.True(a.ContentEquals(b)); + } + + [Fact] + public void DifferentMessageIdReturnsFalse() + { + ChatMessage a = new(ChatRole.User, "Hello") { MessageId = "msg-1" }; + ChatMessage b = new(ChatRole.User, "Hello") { MessageId = "msg-2" }; + + Assert.False(a.ContentEquals(b)); + } + + [Fact] + public void OnlyLeftHasMessageIdFallsThroughToContentComparison() + { + ChatMessage a = new(ChatRole.User, "Hello") { MessageId = "msg-1" }; + ChatMessage b = new(ChatRole.User, "Hello"); + + Assert.True(a.ContentEquals(b)); + } + + [Fact] + public void OnlyRightHasMessageIdFallsThroughToContentComparison() + { + ChatMessage a = new(ChatRole.User, "Hello"); + ChatMessage b = new(ChatRole.User, "Hello") { MessageId = "msg-1" }; + + Assert.True(a.ContentEquals(b)); + } + + #endregion + + #region Role and AuthorName + + [Fact] + public void DifferentRoleReturnsFalse() + { + ChatMessage a = new(ChatRole.User, "Hello"); + ChatMessage b = new(ChatRole.Assistant, "Hello"); + + Assert.False(a.ContentEquals(b)); + } + + [Fact] + public void DifferentAuthorNameReturnsFalse() + { + ChatMessage a = new(ChatRole.User, "Hello") { AuthorName = "Alice" }; + ChatMessage b = new(ChatRole.User, "Hello") { AuthorName = "Bob" }; + + Assert.False(a.ContentEquals(b)); + } + + [Fact] + public void BothNullAuthorNamesAreEqual() + { + ChatMessage a = new(ChatRole.User, "Hello"); + ChatMessage b = new(ChatRole.User, "Hello"); + + Assert.True(a.ContentEquals(b)); + } + + #endregion + + #region TextContent + + [Fact] + public void EqualTextContentReturnsTrue() + { + ChatMessage a = new(ChatRole.User, "Hello world"); + ChatMessage b = new(ChatRole.User, "Hello world"); + + Assert.True(a.ContentEquals(b)); + } + + [Fact] + public void DifferentTextContentReturnsFalse() + { + ChatMessage a = new(ChatRole.User, "Hello"); + ChatMessage b = new(ChatRole.User, "Goodbye"); + + Assert.False(a.ContentEquals(b)); + } + + [Fact] + public void TextContentIsCaseSensitive() + { + ChatMessage a = new(ChatRole.User, "Hello"); + ChatMessage b = new(ChatRole.User, "hello"); + + Assert.False(a.ContentEquals(b)); + } + + #endregion + + #region TextReasoningContent + + [Fact] + public void EqualTextReasoningContentReturnsTrue() + { + ChatMessage a = new(ChatRole.Assistant, [new TextReasoningContent("thinking...") { ProtectedData = "opaque" }]); + ChatMessage b = new(ChatRole.Assistant, [new TextReasoningContent("thinking...") { ProtectedData = "opaque" }]); + + Assert.True(a.ContentEquals(b)); + } + + [Fact] + public void DifferentReasoningTextReturnsFalse() + { + ChatMessage a = new(ChatRole.Assistant, [new TextReasoningContent("alpha")]); + ChatMessage b = new(ChatRole.Assistant, [new TextReasoningContent("beta")]); + + Assert.False(a.ContentEquals(b)); + } + + [Fact] + public void DifferentProtectedDataReturnsFalse() + { + ChatMessage a = new(ChatRole.Assistant, [new TextReasoningContent("same") { ProtectedData = "x" }]); + ChatMessage b = new(ChatRole.Assistant, [new TextReasoningContent("same") { ProtectedData = "y" }]); + + Assert.False(a.ContentEquals(b)); + } + + #endregion + + #region DataContent + + [Fact] + public void EqualDataContentReturnsTrue() + { + byte[] data = Encoding.UTF8.GetBytes("payload"); + ChatMessage a = new(ChatRole.User, [new DataContent(data, "application/octet-stream") { Name = "file.bin" }]); + ChatMessage b = new(ChatRole.User, [new DataContent(data, "application/octet-stream") { Name = "file.bin" }]); + + Assert.True(a.ContentEquals(b)); + } + + [Fact] + public void DifferentDataBytesReturnsFalse() + { + ChatMessage a = new(ChatRole.User, [new DataContent(Encoding.UTF8.GetBytes("aaa"), "text/plain")]); + ChatMessage b = new(ChatRole.User, [new DataContent(Encoding.UTF8.GetBytes("bbb"), "text/plain")]); + + Assert.False(a.ContentEquals(b)); + } + + [Fact] + public void DifferentMediaTypeReturnsFalse() + { + byte[] data = [1, 2, 3]; + ChatMessage a = new(ChatRole.User, [new DataContent(data, "image/png")]); + ChatMessage b = new(ChatRole.User, [new DataContent(data, "image/jpeg")]); + + Assert.False(a.ContentEquals(b)); + } + + [Fact] + public void DifferentDataContentNameReturnsFalse() + { + byte[] data = [1, 2, 3]; + ChatMessage a = new(ChatRole.User, [new DataContent(data, "image/png") { Name = "a.png" }]); + ChatMessage b = new(ChatRole.User, [new DataContent(data, "image/png") { Name = "b.png" }]); + + Assert.False(a.ContentEquals(b)); + } + + #endregion + + #region UriContent + + [Fact] + public void EqualUriContentReturnsTrue() + { + ChatMessage a = new(ChatRole.User, [new UriContent(new Uri("https://example.com/image.png"), "image/png")]); + ChatMessage b = new(ChatRole.User, [new UriContent(new Uri("https://example.com/image.png"), "image/png")]); + + Assert.True(a.ContentEquals(b)); + } + + [Fact] + public void DifferentUriReturnsFalse() + { + ChatMessage a = new(ChatRole.User, [new UriContent(new Uri("https://a.com/x"), "image/png")]); + ChatMessage b = new(ChatRole.User, [new UriContent(new Uri("https://b.com/x"), "image/png")]); + + Assert.False(a.ContentEquals(b)); + } + + [Fact] + public void DifferentUriMediaTypeReturnsFalse() + { + Uri uri = new("https://example.com/file"); + ChatMessage a = new(ChatRole.User, [new UriContent(uri, "image/png")]); + ChatMessage b = new(ChatRole.User, [new UriContent(uri, "image/jpeg")]); + + Assert.False(a.ContentEquals(b)); + } + + #endregion + + #region ErrorContent + + [Fact] + public void EqualErrorContentReturnsTrue() + { + ChatMessage a = new(ChatRole.Assistant, [new ErrorContent("fail") { ErrorCode = "E001" }]); + ChatMessage b = new(ChatRole.Assistant, [new ErrorContent("fail") { ErrorCode = "E001" }]); + + Assert.True(a.ContentEquals(b)); + } + + [Fact] + public void DifferentErrorMessageReturnsFalse() + { + ChatMessage a = new(ChatRole.Assistant, [new ErrorContent("fail")]); + ChatMessage b = new(ChatRole.Assistant, [new ErrorContent("crash")]); + + Assert.False(a.ContentEquals(b)); + } + + [Fact] + public void DifferentErrorCodeReturnsFalse() + { + ChatMessage a = new(ChatRole.Assistant, [new ErrorContent("fail") { ErrorCode = "E001" }]); + ChatMessage b = new(ChatRole.Assistant, [new ErrorContent("fail") { ErrorCode = "E002" }]); + + Assert.False(a.ContentEquals(b)); + } + + #endregion + + #region FunctionCallContent + + [Fact] + public void EqualFunctionCallContentReturnsTrue() + { + ChatMessage a = new(ChatRole.Assistant, [new FunctionCallContent("call-1", "get_weather") { Arguments = new Dictionary { ["city"] = "Seattle" } }]); + ChatMessage b = new(ChatRole.Assistant, [new FunctionCallContent("call-1", "get_weather") { Arguments = new Dictionary { ["city"] = "Seattle" } }]); + + Assert.True(a.ContentEquals(b)); + } + + [Fact] + public void DifferentCallIdReturnsFalse() + { + ChatMessage a = new(ChatRole.Assistant, [new FunctionCallContent("call-1", "get_weather")]); + ChatMessage b = new(ChatRole.Assistant, [new FunctionCallContent("call-2", "get_weather")]); + + Assert.False(a.ContentEquals(b)); + } + + [Fact] + public void DifferentFunctionNameReturnsFalse() + { + ChatMessage a = new(ChatRole.Assistant, [new FunctionCallContent("call-1", "get_weather")]); + ChatMessage b = new(ChatRole.Assistant, [new FunctionCallContent("call-1", "get_time")]); + + Assert.False(a.ContentEquals(b)); + } + + [Fact] + public void DifferentArgumentsReturnsFalse() + { + ChatMessage a = new(ChatRole.Assistant, [new FunctionCallContent("call-1", "fn") { Arguments = new Dictionary { ["x"] = "1" } }]); + ChatMessage b = new(ChatRole.Assistant, [new FunctionCallContent("call-1", "fn") { Arguments = new Dictionary { ["x"] = "2" } }]); + + Assert.False(a.ContentEquals(b)); + } + + [Fact] + public void NullArgumentsBothSidesReturnsTrue() + { + ChatMessage a = new(ChatRole.Assistant, [new FunctionCallContent("call-1", "fn")]); + ChatMessage b = new(ChatRole.Assistant, [new FunctionCallContent("call-1", "fn")]); + + Assert.True(a.ContentEquals(b)); + } + + [Fact] + public void OneNullArgumentsReturnsFalse() + { + ChatMessage a = new(ChatRole.Assistant, [new FunctionCallContent("call-1", "fn")]); + ChatMessage b = new(ChatRole.Assistant, [new FunctionCallContent("call-1", "fn") { Arguments = new Dictionary { ["x"] = "1" } }]); + + Assert.False(a.ContentEquals(b)); + } + + [Fact] + public void DifferentArgumentCountReturnsFalse() + { + ChatMessage a = new(ChatRole.Assistant, [new FunctionCallContent("call-1", "fn") { Arguments = new Dictionary { ["x"] = "1" } }]); + ChatMessage b = new(ChatRole.Assistant, [new FunctionCallContent("call-1", "fn") { Arguments = new Dictionary { ["x"] = "1", ["y"] = "2" } }]); + + Assert.False(a.ContentEquals(b)); + } + + #endregion + + #region FunctionResultContent + + [Fact] + public void EqualFunctionResultContentReturnsTrue() + { + ChatMessage a = new(ChatRole.Tool, [new FunctionResultContent("call-1", "sunny")]); + ChatMessage b = new(ChatRole.Tool, [new FunctionResultContent("call-1", "sunny")]); + + Assert.True(a.ContentEquals(b)); + } + + [Fact] + public void DifferentResultCallIdReturnsFalse() + { + ChatMessage a = new(ChatRole.Tool, [new FunctionResultContent("call-1", "sunny")]); + ChatMessage b = new(ChatRole.Tool, [new FunctionResultContent("call-2", "sunny")]); + + Assert.False(a.ContentEquals(b)); + } + + [Fact] + public void DifferentResultValueReturnsFalse() + { + ChatMessage a = new(ChatRole.Tool, [new FunctionResultContent("call-1", "sunny")]); + ChatMessage b = new(ChatRole.Tool, [new FunctionResultContent("call-1", "rainy")]); + + Assert.False(a.ContentEquals(b)); + } + + #endregion + + #region HostedFileContent + + [Fact] + public void EqualHostedFileContentReturnsTrue() + { + ChatMessage a = new(ChatRole.User, [new HostedFileContent("file-abc") { MediaType = "text/csv", Name = "data.csv" }]); + ChatMessage b = new(ChatRole.User, [new HostedFileContent("file-abc") { MediaType = "text/csv", Name = "data.csv" }]); + + Assert.True(a.ContentEquals(b)); + } + + [Fact] + public void DifferentFileIdReturnsFalse() + { + ChatMessage a = new(ChatRole.User, [new HostedFileContent("file-abc")]); + ChatMessage b = new(ChatRole.User, [new HostedFileContent("file-xyz")]); + + Assert.False(a.ContentEquals(b)); + } + + [Fact] + public void DifferentHostedFileMediaTypeReturnsFalse() + { + ChatMessage a = new(ChatRole.User, [new HostedFileContent("file-abc") { MediaType = "text/csv" }]); + ChatMessage b = new(ChatRole.User, [new HostedFileContent("file-abc") { MediaType = "text/plain" }]); + + Assert.False(a.ContentEquals(b)); + } + + [Fact] + public void DifferentHostedFileNameReturnsFalse() + { + ChatMessage a = new(ChatRole.User, [new HostedFileContent("file-abc") { Name = "a.csv" }]); + ChatMessage b = new(ChatRole.User, [new HostedFileContent("file-abc") { Name = "b.csv" }]); + + Assert.False(a.ContentEquals(b)); + } + + #endregion + + #region Content list structure + + [Fact] + public void DifferentContentCountReturnsFalse() + { + ChatMessage a = new(ChatRole.User, [new TextContent("one"), new TextContent("two")]); + ChatMessage b = new(ChatRole.User, [new TextContent("one")]); + + Assert.False(a.ContentEquals(b)); + } + + [Fact] + public void MixedContentTypesInSameOrderReturnsTrue() + { + ChatMessage a = new(ChatRole.Assistant, new AIContent[] { new TextContent("reply"), new FunctionCallContent("c1", "fn") }); + ChatMessage b = new(ChatRole.Assistant, new AIContent[] { new TextContent("reply"), new FunctionCallContent("c1", "fn") }); + + Assert.True(a.ContentEquals(b)); + } + + [Fact] + public void MismatchedContentTypeOrderReturnsFalse() + { + ChatMessage a = new(ChatRole.Assistant, new AIContent[] { new TextContent("reply"), new FunctionCallContent("c1", "fn") }); + ChatMessage b = new(ChatRole.Assistant, new AIContent[] { new FunctionCallContent("c1", "fn"), new TextContent("reply") }); + + Assert.False(a.ContentEquals(b)); + } + + [Fact] + public void EmptyContentsListsAreEqual() + { + ChatMessage a = new() { Role = ChatRole.User, Contents = [] }; + ChatMessage b = new() { Role = ChatRole.User, Contents = [] }; + + Assert.True(a.ContentEquals(b)); + } + + [Fact] + public void SameContentItemReferenceReturnsTrue() + { + // Exercises the ReferenceEquals fast-path on individual AIContent items. + TextContent shared = new("Hello"); + ChatMessage a = new(ChatRole.User, [shared]); + ChatMessage b = new(ChatRole.User, [shared]); + + Assert.True(a.ContentEquals(b)); + } + + #endregion + + #region Unknown AIContent subtype + + [Fact] + public void UnknownContentSubtypeSameTypeReturnsTrue() + { + // Unknown subtypes with the same concrete type are considered equal. + ChatMessage a = new(ChatRole.User, [new StubContent()]); + ChatMessage b = new(ChatRole.User, [new StubContent()]); + + Assert.True(a.ContentEquals(b)); + } + + [Fact] + public void DifferentUnknownContentSubtypesReturnFalse() + { + ChatMessage a = new(ChatRole.User, [new StubContent()]); + ChatMessage b = new(ChatRole.User, [new OtherStubContent()]); + + Assert.False(a.ContentEquals(b)); + } + + private sealed class StubContent : AIContent; + + private sealed class OtherStubContent : AIContent; + + #endregion +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/MessageIndexTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/MessageIndexTests.cs index 3c3654da39..9f4ee825b6 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/MessageIndexTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/MessageIndexTests.cs @@ -555,7 +555,7 @@ public void UpdateAppendsNewMessagesIncrementally() ]; MessageIndex index = MessageIndex.Create(messages); Assert.Equal(2, index.Groups.Count); - Assert.Equal(2, index.ProcessedMessageCount); + Assert.Equal(2, index.RawMessageCount); // Act — add 2 more messages and update messages.Add(new ChatMessage(ChatRole.User, "Q2")); @@ -564,7 +564,7 @@ public void UpdateAppendsNewMessagesIncrementally() // Assert — should have 4 groups total, processed count updated Assert.Equal(4, index.Groups.Count); - Assert.Equal(4, index.ProcessedMessageCount); + Assert.Equal(4, index.RawMessageCount); Assert.Equal(MessageGroupKind.User, index.Groups[2].Kind); Assert.Equal(MessageGroupKind.AssistantText, index.Groups[3].Kind); } @@ -613,7 +613,56 @@ public void UpdateRebuildsWhenMessagesShrink() // Assert — rebuilt from scratch Assert.Single(index.Groups); Assert.False(index.Groups[0].IsExcluded); - Assert.Equal(1, index.ProcessedMessageCount); + Assert.Equal(1, index.RawMessageCount); + } + + [Fact] + public void UpdateWithEmptyListClearsGroups() + { + // Arrange — create with messages + List messages = + [ + new ChatMessage(ChatRole.User, "Q1"), + new ChatMessage(ChatRole.Assistant, "A1"), + ]; + MessageIndex index = MessageIndex.Create(messages); + Assert.Equal(2, index.Groups.Count); + + // Act — update with empty list + index.Update([]); + + // Assert — fully cleared + Assert.Empty(index.Groups); + Assert.Equal(0, index.TotalTurnCount); + Assert.Equal(0, index.RawMessageCount); + } + + [Fact] + public void UpdateRebuildsWhenLastProcessedMessageNotFound() + { + // Arrange — create with messages + List messages = + [ + new ChatMessage(ChatRole.User, "Q1"), + new ChatMessage(ChatRole.Assistant, "A1"), + ]; + MessageIndex index = MessageIndex.Create(messages); + Assert.Equal(2, index.Groups.Count); + index.Groups[0].IsExcluded = true; + + // Act — update with completely different messages (last processed "A1" is absent) + List replaced = + [ + new ChatMessage(ChatRole.User, "X1"), + new ChatMessage(ChatRole.Assistant, "X2"), + new ChatMessage(ChatRole.User, "X3"), + ]; + index.Update(replaced); + + // Assert — rebuilt from scratch, exclusion state gone + Assert.Equal(3, index.Groups.Count); + Assert.All(index.Groups, g => Assert.False(g.IsExcluded)); + Assert.Equal(3, index.RawMessageCount); } [Fact] From 2c37cfa3483b8179a664cb9ce43c07aa05553342 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Mon, 9 Mar 2026 21:59:17 -0700 Subject: [PATCH 54/80] Fix sliding window perf --- .../SlidingWindowCompactionStrategy.cs | 92 +++++++++++-------- 1 file changed, 54 insertions(+), 38 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/SlidingWindowCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/SlidingWindowCompactionStrategy.cs index 3d3b25c5fb..ac33a81aff 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/SlidingWindowCompactionStrategy.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/SlidingWindowCompactionStrategy.cs @@ -3,7 +3,6 @@ using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; -using System.Linq; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; @@ -69,60 +68,77 @@ public SlidingWindowCompactionStrategy(CompactionTrigger trigger, int minimumPre /// protected override ValueTask CompactCoreAsync(MessageIndex index, ILogger logger, CancellationToken cancellationToken) { - // Identify protected groups: the N most-recent non-system, non-excluded groups - int[] nonSystemIncludedIndices = - index.Groups - .Select((group, index) => (group, index)) - .Where(tuple => !tuple.group.IsExcluded && tuple.group.Kind != MessageGroupKind.System) - .Select(tuple => tuple.index) - .ToArray(); - - int protectedStart = Math.Max(0, nonSystemIncludedIndices.Length - this.MinimumPreserved); - HashSet protectedGroupIndices = [.. nonSystemIncludedIndices.Skip(protectedStart)]; - - // Collect distinct included turn indices in order (oldest first), excluding protected groups - List excludableTurns = []; - HashSet processedTurns = []; + // Forward pass: count non-system included groups and pre-index them by TurnIndex. + int nonSystemIncludedCount = 0; + Dictionary> turnGroups = []; + List turnOrder = []; + for (int i = 0; i < index.Groups.Count; i++) { MessageGroup group = index.Groups[i]; - if (!group.IsExcluded - && group.Kind != MessageGroupKind.System - && !protectedGroupIndices.Contains(i) - && group.TurnIndex is int turnIndex - && !processedTurns.Contains(turnIndex)) + if (!group.IsExcluded && group.Kind != MessageGroupKind.System) { - excludableTurns.Add(turnIndex); - processedTurns.Add(turnIndex); + nonSystemIncludedCount++; + + if (group.TurnIndex is int turnIndex) + { + if (!turnGroups.TryGetValue(turnIndex, out List? indices)) + { + indices = []; + turnGroups[turnIndex] = indices; + turnOrder.Add(turnIndex); + } + + indices.Add(i); + } } } - // Exclude one turn at a time from oldest, re-checking target after each + // Backward pass: identify the protected tail (last MinimumPreserved non-system included groups). + int protectedCount = Math.Min(this.MinimumPreserved, nonSystemIncludedCount); +#if NETSTANDARD + HashSet protectedIndices = []; +#else + HashSet protectedIndices = new(protectedCount); +#endif + int remaining = protectedCount; + for (int i = index.Groups.Count - 1; i >= 0 && remaining > 0; i--) + { + MessageGroup group = index.Groups[i]; + if (!group.IsExcluded && group.Kind != MessageGroupKind.System) + { + protectedIndices.Add(i); + remaining--; + } + } + + // Exclude turns oldest-first using the pre-built index, checking target after each turn. bool compacted = false; - for (int t = 0; t < excludableTurns.Count; t++) + for (int t = 0; t < turnOrder.Count; t++) { - int turnToExclude = excludableTurns[t]; + List groupIndices = turnGroups[turnOrder[t]]; + bool anyExcluded = false; - for (int i = 0; i < index.Groups.Count; i++) + for (int g = 0; g < groupIndices.Count; g++) { - MessageGroup group = index.Groups[i]; - if (!group.IsExcluded - && group.Kind != MessageGroupKind.System - && !protectedGroupIndices.Contains(i) - && group.TurnIndex == turnToExclude) + int idx = groupIndices[g]; + if (!protectedIndices.Contains(idx)) { - group.IsExcluded = true; - group.ExcludeReason = $"Excluded by {nameof(SlidingWindowCompactionStrategy)}"; + index.Groups[idx].IsExcluded = true; + index.Groups[idx].ExcludeReason = $"Excluded by {nameof(SlidingWindowCompactionStrategy)}"; + anyExcluded = true; } } - compacted = true; - - // Stop when target condition is met - if (this.Target(index)) + if (anyExcluded) { - break; + compacted = true; + + if (this.Target(index)) + { + break; + } } } From 7640783b36d1f1becdd5dd49d6cf5e541b6f8d5d Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Mon, 9 Mar 2026 22:29:18 -0700 Subject: [PATCH 55/80] Stable state keys --- .../Compaction/CompactionProvider.cs | 6 ++++-- .../Compaction/CompactionProviderTests.cs | 15 +++++++++++++-- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionProvider.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionProvider.cs index 14aba7257a..8873213e76 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionProvider.cs @@ -43,7 +43,9 @@ public sealed class CompactionProvider : AIContextProvider /// /// The compaction strategy to apply before each invocation. /// - /// An optional key used to store the provider state in the . + /// An optional key used to store the provider state in the . Provide + /// an explicit value if configuring multiple agents with different compaction strategies that will interact + /// in the same session. /// /// /// An optional used to create a logger for provider diagnostics. @@ -53,7 +55,7 @@ public sealed class CompactionProvider : AIContextProvider public CompactionProvider(CompactionStrategy compactionStrategy, string? stateKey = null, ILoggerFactory? loggerFactory = null) { this._compactionStrategy = Throw.IfNull(compactionStrategy); - stateKey ??= $"{nameof(CompactionProvider)}:{Convert.ToBase64String(BitConverter.GetBytes(compactionStrategy.GetHashCode()))}"; + stateKey ??= compactionStrategy.GetType().Name; this.StateKeys = [stateKey]; this._sessionState = new ProviderSessionState( _ => new State(), diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionProviderTests.cs index 8344ee265d..358c4e591b 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionProviderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionProviderTests.cs @@ -27,9 +27,20 @@ public void StateKeysReturnsExpectedKey() TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(100000)); CompactionProvider provider = new(strategy); - // Act & Assert — default state key is the class name + // Act & Assert — default state key is provider name + strategy type name Assert.Single(provider.StateKeys); - Assert.Contains(nameof(CompactionProvider), provider.StateKeys[0]); + Assert.Equal(nameof(TruncationCompactionStrategy), provider.StateKeys[0]); + } + + [Fact] + public void StateKeysAreStableAcrossEquivalentInstances() + { + // Arrange — two providers with equivalent (but distinct) strategies + CompactionProvider provider1 = new(new TruncationCompactionStrategy(CompactionTriggers.TokensExceed(100000))); + CompactionProvider provider2 = new(new TruncationCompactionStrategy(CompactionTriggers.TokensExceed(100000))); + + // Act & Assert — default keys must be identical for session state stability + Assert.Equal(provider1.StateKeys[0], provider2.StateKeys[0]); } [Fact] From 9a46d1830e40358663b25b0880db486fecbf4ef4 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Mon, 9 Mar 2026 22:48:38 -0700 Subject: [PATCH 56/80] Increase size computation --- .../Compaction/MessageIndex.cs | 96 ++++++- .../Compaction/MessageIndexTests.cs | 255 ++++++++++++++++-- 2 files changed, 321 insertions(+), 30 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/MessageIndex.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/MessageIndex.cs index ddd3eade0c..bafb9df7b2 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/MessageIndex.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/MessageIndex.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; @@ -337,19 +338,19 @@ public IEnumerable GetIncludedMessages() => public IEnumerable GetTurnGroups(int turnIndex) => this.Groups.Where(group => group.TurnIndex == turnIndex); /// - /// Computes the UTF-8 byte count for a set of messages. + /// Computes the UTF-8 byte count for a set of messages across all content types. /// /// The messages to compute byte count for. - /// The total UTF-8 byte count of all message text content. + /// The total UTF-8 byte count of all message content. internal static int ComputeByteCount(IReadOnlyList messages) { int total = 0; for (int i = 0; i < messages.Count; i++) { - string text = messages[i].Text; - if (text.Length > 0) + IList contents = messages[i].Contents; + for (int j = 0; j < contents.Count; j++) { - total += Encoding.UTF8.GetByteCount(text); + total += ComputeContentByteCount(contents[j]); } } @@ -361,22 +362,99 @@ internal static int ComputeByteCount(IReadOnlyList messages) /// /// The messages to compute token count for. /// The tokenizer to use for counting tokens. - /// The total token count across all message text content. + /// The total token count across all message content. + /// + /// Text-bearing content ( and ) + /// is tokenized directly. All other content types estimate tokens as byteCount / 4. + /// internal static int ComputeTokenCount(IReadOnlyList messages, Tokenizer tokenizer) { int total = 0; for (int i = 0; i < messages.Count; i++) { - string text = messages[i].Text; - if (text.Length > 0) + IList contents = messages[i].Contents; + for (int j = 0; j < contents.Count; j++) { - total += tokenizer.CountTokens(text); + AIContent content = contents[j]; + switch (content) + { + case TextContent text: + if (text.Text is { Length: > 0 } t) + { + total += tokenizer.CountTokens(t); + } + + break; + + case TextReasoningContent reasoning: + if (reasoning.Text is { Length: > 0 } rt) + { + total += tokenizer.CountTokens(rt); + } + + if (reasoning.ProtectedData is { Length: > 0 } pd) + { + total += tokenizer.CountTokens(pd); + } + + break; + + default: + total += ComputeContentByteCount(content) / 4; + break; + } } } return total; } + private static int ComputeContentByteCount(AIContent content) + { + switch (content) + { + case TextContent text: + return GetStringByteCount(text.Text); + + case TextReasoningContent reasoning: + return GetStringByteCount(reasoning.Text) + GetStringByteCount(reasoning.ProtectedData); + + case DataContent data: + return data.Data.Length + GetStringByteCount(data.MediaType) + GetStringByteCount(data.Name); + + case UriContent uri: + return (uri.Uri is Uri uriValue ? GetStringByteCount(uriValue.OriginalString) : 0) + GetStringByteCount(uri.MediaType); + + case FunctionCallContent call: + int callBytes = GetStringByteCount(call.CallId) + GetStringByteCount(call.Name); + if (call.Arguments is not null) + { + foreach (KeyValuePair arg in call.Arguments) + { + callBytes += GetStringByteCount(arg.Key); + callBytes += GetStringByteCount(arg.Value?.ToString()); + } + } + + return callBytes; + + case FunctionResultContent result: + return GetStringByteCount(result.CallId) + GetStringByteCount(result.Result?.ToString()); + + case ErrorContent error: + return GetStringByteCount(error.Message) + GetStringByteCount(error.ErrorCode) + GetStringByteCount(error.Details); + + case HostedFileContent file: + return GetStringByteCount(file.FileId) + GetStringByteCount(file.MediaType) + GetStringByteCount(file.Name); + + default: + return 0; + } + } + + private static int GetStringByteCount(string? value) => + value is { Length: > 0 } ? Encoding.UTF8.GetByteCount(value) : 0; + private static MessageGroup CreateGroup(MessageGroupKind kind, IReadOnlyList messages, Tokenizer? tokenizer, int? turnIndex) { int byteCount = ComputeByteCount(messages); diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/MessageIndexTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/MessageIndexTests.cs index 9f4ee825b6..44be0d1e40 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; using System.Buffers; using System.Collections.Generic; using Microsoft.Agents.AI.Compaction; @@ -305,7 +306,7 @@ public void CreateComputesByteCountMultiByteChars() [Fact] public void CreateComputesByteCountMultipleMessagesInGroup() { - // Arrange — ToolCall group: assistant (tool call, null text) + tool result "OK" (2 bytes) + // Arrange — ToolCall group: assistant (tool call) + tool result "OK" (2 bytes) ChatMessage assistantMsg = new(ChatRole.Assistant, [new FunctionCallContent("call1", "fn")]); ChatMessage toolResult = new(ChatRole.Tool, "OK"); MessageIndex groups = MessageIndex.Create([assistantMsg, toolResult]); @@ -313,7 +314,7 @@ public void CreateComputesByteCountMultipleMessagesInGroup() // 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 + Assert.Equal(9, groups.Groups[0].ByteCount); // FunctionCallContent: "call1" (5) + "fn" (2) = 7, "OK" = 2 → 9 total } [Fact] @@ -328,17 +329,17 @@ public void CreateDefaultTokenCountIsHeuristic() } [Fact] - public void CreateNullTextHasZeroCounts() + public void CreateNonTextContentHasAccurateCounts() { - // Arrange — message with no text (e.g., pure function call) + // Arrange — message with pure function call (no text) ChatMessage msg = new(ChatRole.Assistant, [new FunctionCallContent("call1", "get_weather")]); ChatMessage tool = new(ChatRole.Tool, string.Empty); MessageIndex groups = MessageIndex.Create([msg, tool]); - // Assert + // Assert — FunctionCallContent: "call1" (5) + "get_weather" (11) = 16 bytes Assert.Equal(2, groups.Groups[0].MessageCount); - Assert.Equal(0, groups.Groups[0].ByteCount); - Assert.Equal(0, groups.Groups[0].TokenCount); + Assert.Equal(16, groups.Groups[0].ByteCount); + Assert.Equal(4, groups.Groups[0].TokenCount); // 16 / 4 = 4 estimated tokens } [Fact] @@ -387,7 +388,7 @@ public void IncludedAggregatesExcludeMarkedGroups() [Fact] public void ToolCallGroupAggregatesAcrossMessages() { - // Arrange — tool call group with assistant "Ask" (3 bytes) + tool result "OK" (2 bytes) + // Arrange — tool call group with FunctionCallContent + tool result "OK" (2 bytes) ChatMessage assistantMsg = new(ChatRole.Assistant, [new FunctionCallContent("call1", "fn")]); ChatMessage toolResult = new(ChatRole.Tool, "OK"); @@ -396,7 +397,7 @@ public void ToolCallGroupAggregatesAcrossMessages() // 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(9, groups.Groups[0].ByteCount); // FunctionCallContent: "call1" (5) + "fn" (2) = 7, "OK" = 2 → 9 total Assert.Equal(1, groups.TotalGroupCount); Assert.Equal(2, groups.TotalMessageCount); } @@ -817,18 +818,18 @@ public void ComputeTokenCountReturnsTokenCount() } [Fact] - public void ComputeTokenCountEmptyTextReturnsZero() + public void ComputeTokenCountEmptyContentsReturnsZero() { - // Arrange — message with no text content + // Arrange — message with empty contents List messages = [ - new ChatMessage(ChatRole.User, [new FunctionCallContent("c1", "fn")]), + new ChatMessage(ChatRole.User, []), ]; SimpleWordTokenizer tokenizer = new(); int tokenCount = MessageIndex.ComputeTokenCount(messages, tokenizer); - // Assert — no text content → 0 tokens + // Assert — no content → 0 tokens Assert.Equal(0, tokenCount); } @@ -951,9 +952,9 @@ public void CreateWithNoAdditionalPropertiesIsNotSummary() } [Fact] - public void ComputeByteCountHandlesNullAndNonNullText() + public void ComputeByteCountHandlesTextAndNonTextContent() { - // Mix of messages: one with text (non-null), one without (null Text) + // Mix of messages: one with text (non-null), one with FunctionCallContent List messages = [ new ChatMessage(ChatRole.User, "Hello"), @@ -962,14 +963,14 @@ public void ComputeByteCountHandlesNullAndNonNullText() int byteCount = MessageIndex.ComputeByteCount(messages); - // Only "Hello" contributes bytes (5 bytes UTF-8) - Assert.Equal(5, byteCount); + // "Hello" = 5 bytes, FunctionCallContent("c1", "fn") = "c1" (2) + "fn" (2) = 4 bytes + Assert.Equal(9, byteCount); } [Fact] - public void ComputeTokenCountHandlesNullAndNonNullText() + public void ComputeTokenCountHandlesTextAndNonTextContent() { - // Mix: one with text, one without + // Mix: one with text, one with FunctionCallContent SimpleWordTokenizer tokenizer = new(); List messages = [ @@ -979,8 +980,220 @@ public void ComputeTokenCountHandlesNullAndNonNullText() int tokenCount = MessageIndex.ComputeTokenCount(messages, tokenizer); - // Only "Hello world" contributes tokens (2 words) - Assert.Equal(2, tokenCount); + // "Hello world" = 2 tokens (tokenized), FunctionCallContent("c1","fn") = 4 bytes → 1 token (estimated) + Assert.Equal(3, tokenCount); + } + + [Fact] + public void ComputeByteCountTextContent() + { + List messages = + [ + new ChatMessage(ChatRole.User, [new TextContent("Hello")]), + ]; + + Assert.Equal(5, MessageIndex.ComputeByteCount(messages)); + } + + [Fact] + public void ComputeByteCountTextReasoningContent() + { + List messages = + [ + new ChatMessage(ChatRole.Assistant, [new TextReasoningContent("think") { ProtectedData = "secret" }]), + ]; + + // "think" = 5 bytes, "secret" = 6 bytes + Assert.Equal(11, MessageIndex.ComputeByteCount(messages)); + } + + [Fact] + public void ComputeByteCountDataContent() + { + byte[] payload = new byte[100]; + List messages = + [ + new ChatMessage(ChatRole.User, [new DataContent(payload, "image/png") { Name = "pic" }]), + ]; + + // 100 (data) + 9 ("image/png") + 3 ("pic") + Assert.Equal(112, MessageIndex.ComputeByteCount(messages)); + } + + [Fact] + public void ComputeByteCountUriContent() + { + List messages = + [ + new ChatMessage(ChatRole.User, [new UriContent(new Uri("https://example.com/image.png"), "image/png")]), + ]; + + // "https://example.com/image.png" = 29 bytes, "image/png" = 9 bytes + Assert.Equal(38, MessageIndex.ComputeByteCount(messages)); + } + + [Fact] + public void ComputeByteCountFunctionCallContentWithArguments() + { + List messages = + [ + new ChatMessage(ChatRole.Assistant, + [ + new FunctionCallContent("call1", "get_weather", new Dictionary { ["city"] = "Seattle" }), + ]), + ]; + + // "call1" = 5, "get_weather" = 11, "city" = 4, "Seattle" = 7 + Assert.Equal(27, MessageIndex.ComputeByteCount(messages)); + } + + [Fact] + public void ComputeByteCountFunctionCallContentWithoutArguments() + { + List messages = + [ + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "fn")]), + ]; + + // "c1" = 2, "fn" = 2 + Assert.Equal(4, MessageIndex.ComputeByteCount(messages)); + } + + [Fact] + public void ComputeByteCountFunctionResultContent() + { + List messages = + [ + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("call1", "Sunny, 72°F")]), + ]; + + // "call1" = 5, "Sunny, 72°F" = 13 bytes (° is 2 bytes in UTF-8) + Assert.Equal(5 + System.Text.Encoding.UTF8.GetByteCount("Sunny, 72°F"), MessageIndex.ComputeByteCount(messages)); + } + + [Fact] + public void ComputeByteCountErrorContent() + { + List messages = + [ + new ChatMessage(ChatRole.Assistant, [new ErrorContent("fail") { ErrorCode = "E001" }]), + ]; + + // "fail" = 4, "E001" = 4 + Assert.Equal(8, MessageIndex.ComputeByteCount(messages)); + } + + [Fact] + public void ComputeByteCountHostedFileContent() + { + List messages = + [ + new ChatMessage(ChatRole.Assistant, [new HostedFileContent("file-abc") { MediaType = "text/plain", Name = "readme.txt" }]), + ]; + + // "file-abc" = 8, "text/plain" = 10, "readme.txt" = 10 + Assert.Equal(28, MessageIndex.ComputeByteCount(messages)); + } + + [Fact] + public void ComputeByteCountMixedContentInSingleMessage() + { + List messages = + [ + new ChatMessage(ChatRole.User, + [ + new TextContent("Hello"), + new DataContent(new byte[50], "image/png"), + ]), + ]; + + // TextContent: "Hello" = 5 bytes + // DataContent: 50 (data) + 9 ("image/png") = 59 bytes + Assert.Equal(64, MessageIndex.ComputeByteCount(messages)); + } + + [Fact] + public void ComputeByteCountEmptyContentsReturnsZero() + { + List messages = + [ + new ChatMessage(ChatRole.User, []), + ]; + + Assert.Equal(0, MessageIndex.ComputeByteCount(messages)); + } + + [Fact] + public void ComputeByteCountUnknownContentTypeReturnsZero() + { + List messages = + [ + new ChatMessage(ChatRole.Assistant, [new UsageContent(new UsageDetails())]), + ]; + + Assert.Equal(0, MessageIndex.ComputeByteCount(messages)); + } + + [Fact] + public void ComputeTokenCountTextReasoningContentUsesTokenizer() + { + SimpleWordTokenizer tokenizer = new(); + List messages = + [ + new ChatMessage(ChatRole.Assistant, [new TextReasoningContent("deep thinking here") { ProtectedData = "hidden data" }]), + ]; + + // "deep thinking here" = 3 words, "hidden data" = 2 words → 5 tokens via tokenizer + Assert.Equal(5, MessageIndex.ComputeTokenCount(messages, tokenizer)); + } + + [Fact] + public void ComputeTokenCountNonTextContentEstimatesFromBytes() + { + SimpleWordTokenizer tokenizer = new(); + byte[] payload = new byte[40]; + List messages = + [ + new ChatMessage(ChatRole.User, [new DataContent(payload, "image/png")]), + ]; + + // DataContent: 40 (data) + 9 ("image/png") = 49 bytes → 49/4 = 12 tokens (estimated) + Assert.Equal(12, MessageIndex.ComputeTokenCount(messages, tokenizer)); + } + + [Fact] + public void ComputeTokenCountMixedTextAndNonTextContent() + { + SimpleWordTokenizer tokenizer = new(); + List messages = + [ + new ChatMessage(ChatRole.User, + [ + new TextContent("Hello world"), + new DataContent(new byte[40], "image/png"), + ]), + ]; + + // TextContent: "Hello world" = 2 tokens (tokenized) + // DataContent: 40 + 9 = 49 bytes → 12 tokens (estimated) + Assert.Equal(14, MessageIndex.ComputeTokenCount(messages, tokenizer)); + } + + [Fact] + public void CreateGroupByteCountIncludesAllContentTypes() + { + // Verify that MessageIndex.Create produces groups with accurate byte counts for non-text content + ChatMessage assistantMessage = new(ChatRole.Assistant, [new FunctionCallContent("call1", "get_weather", new Dictionary { ["city"] = "Seattle" })]); + ChatMessage toolResult = new(ChatRole.Tool, [new FunctionResultContent("call1", "Sunny")]); + List messages = [assistantMessage, toolResult]; + + MessageIndex index = MessageIndex.Create(messages); + + // ToolCall group: FunctionCallContent("call1","get_weather",{city=Seattle}) + FunctionResultContent("call1","Sunny") + // = (5 + 11 + 4 + 7) + (5 + 5) = 27 + 10 = 37 + Assert.Single(index.Groups); + Assert.Equal(37, index.Groups[0].ByteCount); + Assert.True(index.Groups[0].TokenCount > 0); } /// From 60c4b7ae8009ff7ced2a47b4c660c75d0e5ae658 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Mon, 9 Mar 2026 22:54:08 -0700 Subject: [PATCH 57/80] Formatting --- .../Compaction/MessageIndexTests.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/MessageIndexTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/MessageIndexTests.cs index 44be0d1e40..6c5c0aec9d 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/MessageIndexTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/MessageIndexTests.cs @@ -1204,7 +1204,7 @@ private sealed class SimpleWordTokenizer : Tokenizer public override PreTokenizer? PreTokenizer => null; public override Normalizer? Normalizer => null; - protected override EncodeResults EncodeToTokens(string? text, System.ReadOnlySpan textSpan, EncodeSettings settings) + protected override EncodeResults EncodeToTokens(string? text, ReadOnlySpan textSpan, EncodeSettings settings) { // Simple word-based encoding string input = text ?? textSpan.ToString(); @@ -1212,7 +1212,7 @@ protected override EncodeResults EncodeToTokens(string? text, Syst { return new EncodeResults { - Tokens = System.Array.Empty(), + Tokens = [], CharsConsumed = 0, NormalizedText = null, }; @@ -1223,7 +1223,7 @@ protected override EncodeResults EncodeToTokens(string? text, Syst int offset = 0; for (int i = 0; i < words.Length; i++) { - tokens.Add(new EncodedToken(i, words[i], new System.Range(offset, offset + words[i].Length))); + tokens.Add(new EncodedToken(i, words[i], new Range(offset, offset + words[i].Length))); offset += words[i].Length + 1; } @@ -1235,7 +1235,7 @@ protected override EncodeResults EncodeToTokens(string? text, Syst }; } - public override OperationStatus Decode(IEnumerable ids, System.Span destination, out int idsConsumed, out int charsWritten) + public override OperationStatus Decode(IEnumerable ids, Span destination, out int idsConsumed, out int charsWritten) { idsConsumed = 0; charsWritten = 0; From 1287881851d53442003008ed99d345e470485609 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Mar 2026 23:17:25 -0700 Subject: [PATCH 58/80] Add README.md for Agent_Step18_CompactionPipeline sample (#4574) --- .../Agent_Step18_CompactionPipeline/README.md | 126 ++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/README.md diff --git a/dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/README.md b/dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/README.md new file mode 100644 index 0000000000..db3969a825 --- /dev/null +++ b/dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/README.md @@ -0,0 +1,126 @@ +# Compaction Pipeline + +This sample demonstrates how to use a `CompactionProvider` with a `PipelineCompactionStrategy` to manage long conversation histories in a token-efficient way. The pipeline chains four compaction strategies, ordered from gentle to aggressive, so that the least disruptive strategy runs first and more aggressive strategies only activate when necessary. + +## What This Sample Shows + +- **`CompactionProvider`** — an `AIContextProvider` that applies a compaction strategy before each agent invocation, keeping only the most relevant messages within the model's context window +- **`PipelineCompactionStrategy`** — chains multiple compaction strategies into an ordered pipeline; each strategy evaluates its own trigger independently and operates on the output of the previous one +- **`ToolResultCompactionStrategy`** — collapses older tool-call groups into concise inline summaries (e.g., `[Tool calls: LookupPrice]`), activated by a message-count trigger +- **`SummarizationCompactionStrategy`** — uses an LLM to compress older conversation spans into a single summary message, activated by a token-count trigger +- **`SlidingWindowCompactionStrategy`** — retains only the most recent N user turns and their responses, activated by a turn-count trigger +- **`TruncationCompactionStrategy`** — emergency backstop that drops the oldest groups until the conversation fits within a hard token budget +- **`CompactionTriggers`** — factory methods (`MessagesExceed`, `TokensExceed`, `TurnsExceed`, `GroupsExceed`, `HasToolCalls`, `All`, `Any`) that control when each strategy activates + +## Concepts + +### Message groups + +The compaction engine organizes messages into atomic *groups* that are treated as indivisible units during compaction. A group is either: + +| Group kind | Contents | +|---|---| +| `System` | System prompt message(s) | +| `User` | A single user message | +| `ToolCall` | One assistant message with tool calls + the matching tool result messages | +| `AssistantText` | A single assistant text-only message | + +Strategies exclude entire groups rather than individual messages, preserving the tool-call/result pairing required by most model APIs. + +### Compaction triggers + +A `CompactionTrigger` is a predicate evaluated against the current `MessageIndex`. When the trigger fires, the strategy performs compaction; when it does not fire, the strategy is skipped. Available triggers are: + +| Trigger | Activates when… | +|---|---| +| `CompactionTriggers.Always` | Always (unconditional) | +| `CompactionTriggers.Never` | Never (disabled) | +| `CompactionTriggers.MessagesExceed(n)` | Included message count > n | +| `CompactionTriggers.TokensExceed(n)` | Included token count > n | +| `CompactionTriggers.TurnsExceed(n)` | Included user-turn count > n | +| `CompactionTriggers.GroupsExceed(n)` | Included group count > n | +| `CompactionTriggers.HasToolCalls()` | At least one included tool-call group exists | +| `CompactionTriggers.All(...)` | All supplied triggers fire (logical AND) | +| `CompactionTriggers.Any(...)` | Any supplied trigger fires (logical OR) | + +### Pipeline ordering + +Order strategies from **least aggressive** to **most aggressive**. The pipeline runs every strategy whose trigger is met. Earlier strategies reduce the conversation gently so that later, more destructive strategies may not need to activate at all. + +``` +1. ToolResultCompactionStrategy – gentle: replaces verbose tool results with a short label +2. SummarizationCompactionStrategy – moderate: LLM-summarizes older turns +3. SlidingWindowCompactionStrategy – aggressive: drops turns beyond the window +4. TruncationCompactionStrategy – emergency: hard token-budget enforcement +``` + +## Prerequisites + +- .NET 10 SDK or later +- Azure OpenAI service endpoint and model deployment +- Azure CLI installed and authenticated + +**Note**: This sample uses `DefaultAzureCredential`. Sign in with `az login` before running. For production, prefer a specific credential such as `ManagedIdentityCredential`. For more information, see the [Azure CLI authentication documentation](https://learn.microsoft.com/cli/azure/authenticate-azure-cli-interactively). + +## Environment Variables + +```powershell +$env:AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com/" # Required +$env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini +``` + +## Running the Sample + +```powershell +cd dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline +dotnet run +``` + +## Expected Behavior + +The sample runs a seven-turn shopping-assistant conversation with tool calls. After each turn it prints the current in-memory message count so you can observe the pipeline compacting the history as the conversation grows. + +Each of the four compaction strategies has a deliberately low threshold so that it activates during the short demonstration conversation. In a production scenario you would raise the thresholds to match your model's context window and cost requirements. + +## Customizing the Pipeline + +### Using a single strategy + +If you only need one compaction strategy, pass it directly to `CompactionProvider` without wrapping it in a pipeline: + +```csharp +CompactionProvider provider = + new(new SlidingWindowCompactionStrategy(CompactionTriggers.TurnsExceed(20))); +``` + +### Ad-hoc compaction outside the provider pipeline + +`CompactionProvider.CompactAsync` applies a strategy to an arbitrary list of messages without an active agent session: + +```csharp +IEnumerable compacted = await CompactionProvider.CompactAsync( + new TruncationCompactionStrategy(CompactionTriggers.TokensExceed(8000)), + existingMessages); +``` + +### Using a different model for summarization + +The `SummarizationCompactionStrategy` accepts any `IChatClient`. Use a smaller, cheaper model to reduce summarization cost: + +```csharp +IChatClient summarizerChatClient = openAIClient.GetChatClient("gpt-4o-mini").AsIChatClient(); +new SummarizationCompactionStrategy(summarizerChatClient, CompactionTriggers.TokensExceed(4000)) +``` + +### Registering through `ChatClientAgentOptions` + +`AIContextProviders` can also be specified directly on `ChatClientAgentOptions` instead of calling `UseAIContextProviders` on the builder: + +```csharp +AIAgent agent = agentChatClient + .AsBuilder() + .BuildAIAgent(new ChatClientAgentOptions + { + AIContextProviders = [new CompactionProvider(compactionPipeline)] + }); +``` From 19643d367e1b02fe5a7038ad3fd966b6eee00a13 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Mon, 9 Mar 2026 23:47:05 -0700 Subject: [PATCH 59/80] Sample comments --- .../Agents/Agent_Step18_CompactionPipeline/Program.cs | 3 +++ 1 file changed, 3 insertions(+) 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 cb8baea339..09e948d31c 100644 --- a/dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/Program.cs +++ b/dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/Program.cs @@ -57,6 +57,8 @@ static string LookupPrice([Description("The product name to look up.")] string p AIAgent agent = agentChatClient .AsBuilder() + // Note: Adding the CompactionProvider at the builder level means it will be applied to all agents + // built from this builder and will manage context for both agent messages and tool calls. .UseAIContextProviders(new CompactionProvider(compactionPipeline)) .BuildAIAgent( new ChatClientAgentOptions @@ -74,6 +76,7 @@ many words as possible without sounding ridiculous. Tools = [AIFunctionFactory.Create(LookupPrice)] }, // Note: AIContextProviders may be specified here instead of ChatClientBuilder.UseAIContextProviders. + // Specifying compaction at the agent level skips compaction in the function calling loop. //AIContextProviders = [new CompactionProvider(compactionPipeline)] }); From e79ca625d6aedc0a9faec48a1c53ce0289a6b5a9 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Tue, 10 Mar 2026 00:59:54 -0700 Subject: [PATCH 60/80] Updated --- .../src/Microsoft.Agents.AI/Compaction/MessageIndex.cs | 10 ++++------ .../Compaction/PipelineCompactionStrategy.cs | 5 ++--- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/MessageIndex.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/MessageIndex.cs index bafb9df7b2..333f7c5d49 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/MessageIndex.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/MessageIndex.cs @@ -103,17 +103,15 @@ internal static MessageIndex Create(IList messages, Tokenizer? toke /// /// /// - /// Uses reference equality on the last processed message to detect changes. If the last - /// processed message is found within , only the messages - /// after that position are processed and appended as new groups. Existing groups and their - /// compaction state (exclusions) are preserved. + /// Uses equality on the last processed message to detect changes. Only the messages after that position are + /// processed and appended as new groups. Existing groups and their compaction state (exclusions) are preserved. /// /// - /// If the last processed message is not found (e.group., the message list was replaced entirely + /// If the last processed message is not found (e.g., the message list was replaced entirely /// or a sliding window shifted past it), all groups are cleared and rebuilt from scratch. /// /// - /// If the last message in is the same reference as the last + /// If the last message in is matches the last /// processed message, no work is performed. /// /// diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/PipelineCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/PipelineCompactionStrategy.cs index c3edbfb891..29372f2f9a 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/PipelineCompactionStrategy.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/PipelineCompactionStrategy.cs @@ -20,9 +20,8 @@ namespace Microsoft.Agents.AI.Compaction; /// 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. +/// The pipeline itself always executes while each child strategy evaluates its own +/// independently to decide whether it should compact. /// /// [Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] From 182cefb635d70035cb3d531a603951d90dfcb330 Mon Sep 17 00:00:00 2001 From: Chris <66376200+crickman@users.noreply.github.com> Date: Tue, 10 Mar 2026 09:24:06 -0700 Subject: [PATCH 61/80] Update dotnet/src/Microsoft.Agents.AI/Compaction/MessageIndex.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- dotnet/src/Microsoft.Agents.AI/Compaction/MessageIndex.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/MessageIndex.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/MessageIndex.cs index 333f7c5d49..59da582dbf 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/MessageIndex.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/MessageIndex.cs @@ -111,7 +111,7 @@ internal static MessageIndex Create(IList messages, Tokenizer? toke /// or a sliding window shifted past it), all groups are cleared and rebuilt from scratch. /// /// - /// If the last message in is matches the last + /// If the last message in matches the last /// processed message, no work is performed. /// /// From 4b64b28d0651ca940a984c32404d9f18e342cee8 Mon Sep 17 00:00:00 2001 From: Chris <66376200+crickman@users.noreply.github.com> Date: Tue, 10 Mar 2026 09:38:13 -0700 Subject: [PATCH 62/80] Update dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionProviderTests.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../Compaction/CompactionProviderTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionProviderTests.cs index 358c4e591b..3f78c0690d 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionProviderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionProviderTests.cs @@ -27,7 +27,7 @@ public void StateKeysReturnsExpectedKey() TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(100000)); CompactionProvider provider = new(strategy); - // Act & Assert — default state key is provider name + strategy type name + // Act & Assert — default state key is the strategy type name Assert.Single(provider.StateKeys); Assert.Equal(nameof(TruncationCompactionStrategy), provider.StateKeys[0]); } From 2d8f53f213decb1592043fa5f6947562d2205897 Mon Sep 17 00:00:00 2001 From: Chris <66376200+crickman@users.noreply.github.com> Date: Tue, 10 Mar 2026 10:29:38 -0700 Subject: [PATCH 63/80] Update dotnet/src/Microsoft.Agents.AI/Compaction/MessageIndex.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- dotnet/src/Microsoft.Agents.AI/Compaction/MessageIndex.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/MessageIndex.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/MessageIndex.cs index 59da582dbf..c087885adc 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/MessageIndex.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/MessageIndex.cs @@ -311,7 +311,7 @@ public IEnumerable GetIncludedMessages() => /// /// 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); + public int TotalTurnCount => this.Groups.Select(group => group.TurnIndex).Distinct().Count(turnIndex => turnIndex is not null && turnIndex > 0); /// /// Gets the number of user turns that have at least one non-excluded group. From 10f3538302913ee0404fe7a35b0e6168ad648906 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Tue, 10 Mar 2026 10:37:11 -0700 Subject: [PATCH 64/80] Address copilot comments --- .../Microsoft.Agents.AI/Compaction/CompactionProvider.cs | 2 +- .../Microsoft.Agents.AI/Compaction/CompactionStrategy.cs | 8 ++++++++ dotnet/src/Microsoft.Agents.AI/Compaction/MessageIndex.cs | 5 +++-- .../Compaction/SlidingWindowCompactionStrategy.cs | 2 +- .../Compaction/SummarizationCompactionStrategy.cs | 2 +- .../Compaction/ToolResultCompactionStrategy.cs | 4 ++-- .../Compaction/TruncationCompactionStrategy.cs | 2 +- 7 files changed, 17 insertions(+), 8 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionProvider.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionProvider.cs index 8873213e76..c7bba48dd4 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionProvider.cs @@ -55,7 +55,7 @@ public sealed class CompactionProvider : AIContextProvider public CompactionProvider(CompactionStrategy compactionStrategy, string? stateKey = null, ILoggerFactory? loggerFactory = null) { this._compactionStrategy = Throw.IfNull(compactionStrategy); - stateKey ??= compactionStrategy.GetType().Name; + stateKey ??= this._compactionStrategy.GetType().Name; this.StateKeys = [stateKey]; this._sessionState = new ProviderSessionState( _ => new State(), diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionStrategy.cs index 480b104fe2..8f32396aaa 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionStrategy.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionStrategy.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using System; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Threading; @@ -153,4 +154,11 @@ public async ValueTask CompactAsync(MessageIndex index, ILogger? logger = return compacted; } + + /// + /// Ensures the provided value is not a negative number. + /// + /// The target value. + /// 0 if negative; otherwise the value + protected static int EnsureNonNegative(int value) => Math.Max(0, value); } diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/MessageIndex.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/MessageIndex.cs index 59da582dbf..6a80d5614b 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/MessageIndex.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/MessageIndex.cs @@ -8,6 +8,7 @@ using Microsoft.Extensions.AI; using Microsoft.ML.Tokenizers; using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.AI.Compaction; @@ -43,8 +44,8 @@ public sealed class MessageIndex /// An optional tokenizer retained for computing token counts when adding new groups. public MessageIndex(IList groups, Tokenizer? tokenizer = null) { + this.Groups = Throw.IfNull(groups, nameof(groups)); this.Tokenizer = tokenizer; - this.Groups = groups; // Restore turn counter and last processed message from the groups for (int index = groups.Count - 1; index >= 0; --index) @@ -331,7 +332,7 @@ public IEnumerable GetIncludedMessages() => /// /// Returns all groups that belong to the specified user turn. /// - /// The zero-based turn index. + /// The desired turn index. /// The groups belonging to the turn, in order. public IEnumerable GetTurnGroups(int turnIndex) => this.Groups.Where(group => group.TurnIndex == turnIndex); diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/SlidingWindowCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/SlidingWindowCompactionStrategy.cs index ac33a81aff..6fca61a690 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/SlidingWindowCompactionStrategy.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/SlidingWindowCompactionStrategy.cs @@ -56,7 +56,7 @@ public sealed class SlidingWindowCompactionStrategy : CompactionStrategy public SlidingWindowCompactionStrategy(CompactionTrigger trigger, int minimumPreserved = DefaultMinimumPreserved, CompactionTrigger? target = null) : base(trigger, target) { - this.MinimumPreserved = minimumPreserved; + this.MinimumPreserved = EnsureNonNegative(minimumPreserved); } /// diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/SummarizationCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/SummarizationCompactionStrategy.cs index 7de30fab41..27f1d56c42 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/SummarizationCompactionStrategy.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/SummarizationCompactionStrategy.cs @@ -79,7 +79,7 @@ public SummarizationCompactionStrategy( : base(trigger, target) { this.ChatClient = Throw.IfNull(chatClient); - this.MinimumPreserved = minimumPreserved; + this.MinimumPreserved = EnsureNonNegative(minimumPreserved); this.SummarizationPrompt = summarizationPrompt ?? DefaultSummarizationPrompt; } diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/ToolResultCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/ToolResultCompactionStrategy.cs index 4d4d6c0fce..f957f388c2 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/ToolResultCompactionStrategy.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/ToolResultCompactionStrategy.cs @@ -59,7 +59,7 @@ public sealed class ToolResultCompactionStrategy : CompactionStrategy public ToolResultCompactionStrategy(CompactionTrigger trigger, int minimumPreserved = DefaultMinimumPreserved, CompactionTrigger? target = null) : base(trigger, target) { - this.MinimumPreserved = minimumPreserved; + this.MinimumPreserved = EnsureNonNegative(minimumPreserved); } /// @@ -82,7 +82,7 @@ protected override ValueTask CompactCoreAsync(MessageIndex index, ILogger } } - int protectedStart = Math.Max(0, nonSystemIncludedIndices.Count - this.MinimumPreserved); + int protectedStart = EnsureNonNegative(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 bb13ee7773..2489c0338d 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/TruncationCompactionStrategy.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/TruncationCompactionStrategy.cs @@ -53,7 +53,7 @@ public sealed class TruncationCompactionStrategy : CompactionStrategy public TruncationCompactionStrategy(CompactionTrigger trigger, int minimumPreserved = DefaultMinimumPreserved, CompactionTrigger? target = null) : base(trigger, target) { - this.MinimumPreserved = minimumPreserved; + this.MinimumPreserved = EnsureNonNegative(minimumPreserved); } /// From 1ae8d252d17b070b692823d4eef397fb5b662e5f Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Tue, 10 Mar 2026 10:46:28 -0700 Subject: [PATCH 65/80] Fix namespace --- .../Compaction/ToolResultCompactionStrategy.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/ToolResultCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/ToolResultCompactionStrategy.cs index f957f388c2..0c8f8ee2c2 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/ToolResultCompactionStrategy.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/ToolResultCompactionStrategy.cs @@ -1,6 +1,5 @@ // Copyright (c) Microsoft. All rights reserved. -using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Threading; From dfff5fcd0948eef8d2f28a7db6b8efac779ec0bd Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Tue, 10 Mar 2026 10:59:54 -0700 Subject: [PATCH 66/80] Comments / convensions --- .../InMemoryChatHistoryProvider.cs | 6 +++--- dotnet/src/Microsoft.Agents.AI/Compaction/MessageGroup.cs | 3 +-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/InMemoryChatHistoryProvider.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/InMemoryChatHistoryProvider.cs index 46d8a7d1e3..d3743357ac 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/InMemoryChatHistoryProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/InMemoryChatHistoryProvider.cs @@ -93,7 +93,7 @@ protected override async ValueTask> ProvideChatHistoryA if (this.ReducerTriggerEvent is InMemoryChatHistoryProviderOptions.ChatReducerTriggerEvent.BeforeMessagesRetrieval && this.ChatReducer is not null) { // Apply pre-retrieval reduction if configured - await CompactMessagesAsync(this.ChatReducer, state, cancellationToken).ConfigureAwait(false); + await ReduceMessagesAsync(this.ChatReducer, state, cancellationToken).ConfigureAwait(false); } return state.Messages; @@ -111,11 +111,11 @@ protected override async ValueTask StoreChatHistoryAsync(InvokedContext context, if (this.ReducerTriggerEvent is InMemoryChatHistoryProviderOptions.ChatReducerTriggerEvent.AfterMessageAdded && this.ChatReducer is not null) { // Apply pre-write reduction strategy if configured - await CompactMessagesAsync(this.ChatReducer, state, cancellationToken).ConfigureAwait(false); + await ReduceMessagesAsync(this.ChatReducer, state, cancellationToken).ConfigureAwait(false); } } - private static async Task CompactMessagesAsync(IChatReducer reducer, State state, CancellationToken cancellationToken = default) + private static async Task ReduceMessagesAsync(IChatReducer reducer, State state, CancellationToken cancellationToken = default) { state.Messages = [.. await reducer.ReduceAsync(state.Messages, cancellationToken).ConfigureAwait(false)]; return; diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/MessageGroup.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/MessageGroup.cs index 6e9a81567a..fb68d8fca6 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/MessageGroup.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/MessageGroup.cs @@ -47,8 +47,7 @@ public sealed class MessageGroup /// 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). + /// The user turn this group belongs to, or for . /// [JsonConstructor] internal MessageGroup(MessageGroupKind kind, IReadOnlyList messages, int byteCount, int tokenCount, int? turnIndex = null) From 48fbe27f9182a50aa66ae8ceb11f0b72fd48e80c Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Tue, 10 Mar 2026 11:57:55 -0700 Subject: [PATCH 67/80] Prefix `MessageGroup` and `MessageIndex` --- .../ChatReducerCompactionStrategy.cs | 8 +- ...ageGroupKind.cs => CompactionGroupKind.cs} | 6 +- ...sageGroup.cs => CompactionMessageGroup.cs} | 22 +- ...sageIndex.cs => CompactionMessageIndex.cs} | 72 +++--- .../Compaction/CompactionProvider.cs | 8 +- .../Compaction/CompactionStrategy.cs | 12 +- .../Compaction/CompactionTrigger.cs | 4 +- .../Compaction/CompactionTriggers.cs | 16 +- .../Compaction/PipelineCompactionStrategy.cs | 4 +- .../SlidingWindowCompactionStrategy.cs | 12 +- .../SummarizationCompactionStrategy.cs | 16 +- .../ToolResultCompactionStrategy.cs | 18 +- .../TruncationCompactionStrategy.cs | 10 +- .../ChatReducerCompactionStrategyTests.cs | 24 +- ...ests.cs => CompactionMessageIndexTests.cs} | 236 +++++++++--------- .../Compaction/CompactionProviderTests.cs | 2 +- .../Compaction/CompactionStrategyTests.cs | 30 +-- .../Compaction/CompactionTriggersTests.cs | 32 +-- .../PipelineCompactionStrategyTests.cs | 24 +- .../SlidingWindowCompactionStrategyTests.cs | 20 +- .../SummarizationCompactionStrategyTests.cs | 40 +-- .../ToolResultCompactionStrategyTests.cs | 24 +- .../TruncationCompactionStrategyTests.cs | 32 +-- 23 files changed, 336 insertions(+), 336 deletions(-) rename dotnet/src/Microsoft.Agents.AI/Compaction/{MessageGroupKind.cs => CompactionGroupKind.cs} (89%) rename dotnet/src/Microsoft.Agents.AI/Compaction/{MessageGroup.cs => CompactionMessageGroup.cs} (81%) rename dotnet/src/Microsoft.Agents.AI/Compaction/{MessageIndex.cs => CompactionMessageIndex.cs} (81%) rename dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/{MessageIndexTests.cs => CompactionMessageIndexTests.cs} (78%) diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/ChatReducerCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/ChatReducerCompactionStrategy.cs index f97088950e..3df6736527 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/ChatReducerCompactionStrategy.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/ChatReducerCompactionStrategy.cs @@ -19,7 +19,7 @@ namespace Microsoft.Agents.AI.Compaction; /// /// 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 +/// , passes them to the reducer, and rebuilds the index from the /// reduced message list when the reducer produces fewer messages. /// /// @@ -56,7 +56,7 @@ public ChatReducerCompactionStrategy(IChatReducer chatReducer, CompactionTrigger public IChatReducer ChatReducer { get; } /// - protected override async ValueTask CompactCoreAsync(MessageIndex index, ILogger logger, CancellationToken cancellationToken) + protected override async ValueTask CompactCoreAsync(CompactionMessageIndex index, ILogger logger, CancellationToken cancellationToken) { // No need to short-circuit on empty conversations, this is handled by . List includedMessages = [.. index.GetIncludedMessages()]; @@ -70,9 +70,9 @@ protected override async ValueTask CompactCoreAsync(MessageIndex index, IL } // Rebuild the index from the reduced messages - MessageIndex rebuilt = MessageIndex.Create(reducedMessages, index.Tokenizer); + CompactionMessageIndex rebuilt = CompactionMessageIndex.Create(reducedMessages, index.Tokenizer); index.Groups.Clear(); - foreach (MessageGroup group in rebuilt.Groups) + foreach (CompactionMessageGroup group in rebuilt.Groups) { index.Groups.Add(group); } diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/MessageGroupKind.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionGroupKind.cs similarity index 89% rename from dotnet/src/Microsoft.Agents.AI/Compaction/MessageGroupKind.cs rename to dotnet/src/Microsoft.Agents.AI/Compaction/CompactionGroupKind.cs index 74018d067b..474fab1e9d 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/MessageGroupKind.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionGroupKind.cs @@ -6,7 +6,7 @@ namespace Microsoft.Agents.AI.Compaction; /// -/// Identifies the kind of a . +/// Identifies the kind of a . /// /// /// Message groups are used to classify logically related messages that must be kept together @@ -14,7 +14,7 @@ namespace Microsoft.Agents.AI.Compaction; /// and its corresponding tool result messages form an atomic group. /// [Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] -public enum MessageGroupKind +public enum CompactionGroupKind { /// /// A system message group containing one or more system messages. @@ -47,7 +47,7 @@ public enum MessageGroupKind /// /// /// Summary groups replace previously compacted messages with a condensed representation. - /// They are identified by the metadata entry + /// They are identified by the metadata entry /// on the underlying . /// #pragma warning restore IDE0001 // Simplify Names diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/MessageGroup.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionMessageGroup.cs similarity index 81% rename from dotnet/src/Microsoft.Agents.AI/Compaction/MessageGroup.cs rename to dotnet/src/Microsoft.Agents.AI/Compaction/CompactionMessageGroup.cs index fb68d8fca6..049fa3013f 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/MessageGroup.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionMessageGroup.cs @@ -14,7 +14,7 @@ namespace Microsoft.Agents.AI.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 +/// containing tool calls and its corresponding tool result messages form a /// group — removing one without the other would cause LLM API errors. /// /// @@ -24,33 +24,33 @@ namespace Microsoft.Agents.AI.Compaction; /// /// /// Each group tracks its , , and -/// so that can efficiently aggregate totals across all or only included groups. +/// so that can efficiently aggregate totals across all or only included groups. /// /// [Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] -public sealed class MessageGroup +public sealed class CompactionMessageGroup { /// /// 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 . + /// by . /// public static readonly string SummaryPropertyKey = "_is_summary"; /// - /// Initializes a new instance of the class. + /// 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 user turn this group belongs to, or for . + /// The user turn this group belongs to, or for . /// [JsonConstructor] - internal MessageGroup(MessageGroupKind kind, IReadOnlyList messages, int byteCount, int tokenCount, int? turnIndex = null) + internal CompactionMessageGroup(CompactionGroupKind kind, IReadOnlyList messages, int byteCount, int tokenCount, int? turnIndex = null) { this.Kind = kind; this.Messages = messages; @@ -63,7 +63,7 @@ internal MessageGroup(MessageGroupKind kind, IReadOnlyList messages /// /// Gets the kind of this message group. /// - public MessageGroupKind Kind { get; } + public CompactionGroupKind Kind { get; } /// /// Gets the messages in this group. @@ -93,9 +93,9 @@ internal MessageGroup(MessageGroupKind kind, IReadOnlyList messages /// messages, and so on... /// /// - /// A turn starts with a group and includes all subsequent + /// A turn starts with a group and includes all subsequent /// non-user, non-system groups until the next user group or end of conversation. System messages - /// () are always assigned a turn index + /// () are always assigned a turn index /// since they never belong to a user turn. /// public int? TurnIndex { get; } @@ -105,7 +105,7 @@ internal 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/Compaction/MessageIndex.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionMessageIndex.cs similarity index 81% rename from dotnet/src/Microsoft.Agents.AI/Compaction/MessageIndex.cs rename to dotnet/src/Microsoft.Agents.AI/Compaction/CompactionMessageIndex.cs index f20e1aa244..965deb5b0d 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/MessageIndex.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionMessageIndex.cs @@ -13,16 +13,16 @@ namespace Microsoft.Agents.AI.Compaction; /// -/// A collection of instances and derived metrics based on a flat list of objects. +/// A collection of instances and derived metrics based on a flat list of objects. /// /// -/// provides structural grouping of messages into logical units. Individual +/// provides structural grouping of messages into logical units. Individual /// groups can be marked as excluded without being removed, allowing compaction strategies to toggle visibility while preserving /// the full history for diagnostics or storage. Metrics are provided both including and excluding excluded groups, /// allowing strategies to make informed decisions based on the impact of potential exclusions. /// [Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] -public sealed class MessageIndex +public sealed class CompactionMessageIndex { private int _currentTurn; private ChatMessage? _lastProcessedMessage; @@ -30,7 +30,7 @@ public sealed class MessageIndex /// /// Gets the list of message groups in this collection. /// - public IList Groups { get; } + public IList Groups { get; } /// /// Gets the tokenizer used for computing token counts, or if token counts are estimated. @@ -38,11 +38,11 @@ public sealed class MessageIndex public Tokenizer? Tokenizer { get; } /// - /// Initializes a new instance of the class with the specified groups. + /// 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 MessageIndex(IList groups, Tokenizer? tokenizer = null) + public CompactionMessageIndex(IList groups, Tokenizer? tokenizer = null) { this.Groups = Throw.IfNull(groups, nameof(groups)); this.Tokenizer = tokenizer; @@ -50,7 +50,7 @@ public MessageIndex(IList groups, Tokenizer? tokenizer = null) // Restore turn counter and last processed message from the groups for (int index = groups.Count - 1; index >= 0; --index) { - if (this._lastProcessedMessage is null && this.Groups[index].Kind != MessageGroupKind.Summary) + if (this._lastProcessedMessage is null && this.Groups[index].Kind != CompactionGroupKind.Summary) { IReadOnlyList groupMessages = this.Groups[index].Messages; this._lastProcessedMessage = groupMessages[^1]; @@ -70,27 +70,27 @@ public MessageIndex(IList groups, Tokenizer? tokenizer = null) } /// - /// 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: /// - /// 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. + /// 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. /// /// - internal static MessageIndex Create(IList messages, Tokenizer? tokenizer = null) + internal static CompactionMessageIndex Create(IList messages, Tokenizer? tokenizer = null) { - MessageIndex instance = new([], tokenizer); + CompactionMessageIndex instance = new([], tokenizer); instance.AppendFromMessages(messages, 0); return instance; } @@ -184,13 +184,13 @@ private void AppendFromMessages(IList messages, int startIndex) if (message.Role == ChatRole.System) { // System messages are not part of any turn - this.Groups.Add(CreateGroup(MessageGroupKind.System, [message], this.Tokenizer, turnIndex: null)); + this.Groups.Add(CreateGroup(CompactionGroupKind.System, [message], this.Tokenizer, turnIndex: null)); index++; } else if (message.Role == ChatRole.User) { this._currentTurn++; - this.Groups.Add(CreateGroup(MessageGroupKind.User, [message], this.Tokenizer, this._currentTurn)); + this.Groups.Add(CreateGroup(CompactionGroupKind.User, [message], this.Tokenizer, this._currentTurn)); index++; } else if (message.Role == ChatRole.Assistant && HasToolCalls(message)) @@ -205,16 +205,16 @@ private void AppendFromMessages(IList messages, int startIndex) index++; } - this.Groups.Add(CreateGroup(MessageGroupKind.ToolCall, groupMessages, this.Tokenizer, this._currentTurn)); + this.Groups.Add(CreateGroup(CompactionGroupKind.ToolCall, groupMessages, this.Tokenizer, this._currentTurn)); } else if (message.Role == ChatRole.Assistant && IsSummaryMessage(message)) { - this.Groups.Add(CreateGroup(MessageGroupKind.Summary, [message], this.Tokenizer, this._currentTurn)); + this.Groups.Add(CreateGroup(CompactionGroupKind.Summary, [message], this.Tokenizer, this._currentTurn)); index++; } else { - this.Groups.Add(CreateGroup(MessageGroupKind.AssistantText, [message], this.Tokenizer, this._currentTurn)); + this.Groups.Add(CreateGroup(CompactionGroupKind.AssistantText, [message], this.Tokenizer, this._currentTurn)); index++; } } @@ -226,32 +226,32 @@ private void AppendFromMessages(IList messages, int startIndex) } /// - /// Creates a new with byte and token counts computed using this collection's + /// 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) + /// The newly created . + public CompactionMessageGroup InsertGroup(int index, CompactionGroupKind kind, IReadOnlyList messages, int? turnIndex = null) { - MessageGroup group = CreateGroup(kind, messages, this.Tokenizer, turnIndex); + CompactionMessageGroup 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 + /// 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) + /// The newly created . + public CompactionMessageGroup AddGroup(CompactionGroupKind kind, IReadOnlyList messages, int? turnIndex = null) { - MessageGroup group = CreateGroup(kind, messages, this.Tokenizer, turnIndex); + CompactionMessageGroup group = CreateGroup(kind, messages, this.Tokenizer, turnIndex); this.Groups.Add(group); return group; } @@ -320,21 +320,21 @@ public IEnumerable GetIncludedMessages() => public int IncludedTurnCount => this.Groups.Where(group => !group.IsExcluded && group.TurnIndex is not null && group.TurnIndex > 0).Select(group => group.TurnIndex).Distinct().Count(); /// - /// Gets the total number of groups across all included (non-excluded) groups that are not . + /// Gets the total number of groups across all included (non-excluded) groups that are not . /// - public int IncludedNonSystemGroupCount => this.Groups.Count(group => !group.IsExcluded && group.Kind != MessageGroupKind.System); + public int IncludedNonSystemGroupCount => this.Groups.Count(group => !group.IsExcluded && group.Kind != CompactionGroupKind.System); /// /// Gets the total number of original messages (that are not summaries). /// - public int RawMessageCount => this.Groups.Where(group => group.Kind != MessageGroupKind.Summary).Sum(group => group.MessageCount); + public int RawMessageCount => this.Groups.Where(group => group.Kind != CompactionGroupKind.Summary).Sum(group => group.MessageCount); /// /// Returns all groups that belong to the specified user turn. /// /// The desired turn index. /// The groups belonging to the turn, in order. - public IEnumerable GetTurnGroups(int turnIndex) => this.Groups.Where(group => group.TurnIndex == turnIndex); + public IEnumerable GetTurnGroups(int turnIndex) => this.Groups.Where(group => group.TurnIndex == turnIndex); /// /// Computes the UTF-8 byte count for a set of messages across all content types. @@ -454,14 +454,14 @@ private static int ComputeContentByteCount(AIContent content) private static int GetStringByteCount(string? value) => value is { Length: > 0 } ? Encoding.UTF8.GetByteCount(value) : 0; - private static MessageGroup CreateGroup(MessageGroupKind kind, IReadOnlyList messages, Tokenizer? tokenizer, int? turnIndex) + private static CompactionMessageGroup CreateGroup(CompactionGroupKind 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); + return new CompactionMessageGroup(kind, messages, byteCount, tokenCount, turnIndex); } private static bool HasToolCalls(ChatMessage message) @@ -479,7 +479,7 @@ private static bool HasToolCalls(ChatMessage message) private static bool IsSummaryMessage(ChatMessage message) { - return message.AdditionalProperties?.TryGetValue(MessageGroup.SummaryPropertyKey, out object? value) is true + return message.AdditionalProperties?.TryGetValue(CompactionMessageGroup.SummaryPropertyKey, out object? value) is true && value is true; } } diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionProvider.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionProvider.cs index c7bba48dd4..02891b4f48 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionProvider.cs @@ -82,7 +82,7 @@ public static async Task> CompactAsync(CompactionStrate Throw.IfNull(messages); List messageList = messages as List ?? [.. messages]; - MessageIndex messageIndex = MessageIndex.Create(messageList); + CompactionMessageIndex messageIndex = CompactionMessageIndex.Create(messageList); await compactionStrategy.CompactAsync(messageIndex, logger, cancellationToken).ConfigureAwait(false); @@ -126,7 +126,7 @@ protected override async ValueTask InvokingCoreAsync(InvokingContext State state = this._sessionState.GetOrInitializeState(session); - MessageIndex messageIndex; + CompactionMessageIndex messageIndex; if (state.MessageGroups.Count > 0) { // Update existing index with any new messages appended since the last call. @@ -136,7 +136,7 @@ protected override async ValueTask InvokingCoreAsync(InvokingContext else { // First pass — initialize the message index from scratch. - messageIndex = MessageIndex.Create(messageList); + messageIndex = CompactionMessageIndex.Create(messageList); } string strategyName = this._compactionStrategy.GetType().Name; @@ -181,6 +181,6 @@ internal sealed class State /// Gets or sets the message index groups used for incremental compaction updates. /// [JsonPropertyName("messagegroups")] - public List MessageGroups { get; set; } = []; + public List MessageGroups { get; set; } = []; } } diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionStrategy.cs index 8f32396aaa..e6f7485438 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionStrategy.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionStrategy.cs @@ -13,17 +13,17 @@ namespace Microsoft.Agents.AI.Compaction; /// -/// Base class for strategies that compact a to reduce context size. +/// Base class for strategies that compact 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). /// /// /// Every strategy requires a that determines whether compaction should -/// proceed based on current metrics (token count, message count, turn count, etc.). +/// 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 . /// @@ -42,7 +42,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 /// via . /// /// @@ -91,7 +91,7 @@ protected CompactionStrategy(CompactionTrigger trigger, CompactionTrigger? targe /// The for emitting compaction diagnostics. /// The to monitor for cancellation requests. /// A task whose result is if any compaction was performed, otherwise. - protected abstract ValueTask CompactCoreAsync(MessageIndex index, ILogger logger, CancellationToken cancellationToken); + protected abstract ValueTask CompactCoreAsync(CompactionMessageIndex index, ILogger logger, CancellationToken cancellationToken); /// /// Evaluates the and, when it fires, delegates to @@ -101,7 +101,7 @@ protected CompactionStrategy(CompactionTrigger trigger, CompactionTrigger? targe /// An optional for emitting compaction diagnostics. When , logging is disabled. /// The to monitor for cancellation requests. /// A task representing the asynchronous operation. The task result is if compaction occurred, otherwise. - public async ValueTask CompactAsync(MessageIndex index, ILogger? logger = null, CancellationToken cancellationToken = default) + public async ValueTask CompactAsync(CompactionMessageIndex index, ILogger? logger = null, CancellationToken cancellationToken = default) { string strategyName = this.GetType().Name; logger ??= NullLogger.Instance; diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionTrigger.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionTrigger.cs index ff8bde6008..104d2ccad1 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionTrigger.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionTrigger.cs @@ -6,10 +6,10 @@ namespace Microsoft.Agents.AI.Compaction; /// -/// Defines a condition based on metrics used by a +/// Defines a condition based on metrics used by a /// to determine when to trigger compaction and when the target compaction threshold has been met. /// /// An index over conversation messages that provides group, token, message, and turn metrics. /// to indicate the condition has been met; otherwise . [Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] -public delegate bool CompactionTrigger(MessageIndex index); +public delegate bool CompactionTrigger(CompactionMessageIndex index); diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionTriggers.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionTriggers.cs index 18d83af06d..a2bc398ac3 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionTriggers.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionTriggers.cs @@ -11,7 +11,7 @@ namespace Microsoft.Agents.AI.Compaction; /// /// /// -/// A defines a condition based on metrics used +/// A defines a condition based on metrics used /// by a to determine when to trigger compaction and when the target /// compaction threshold has been met. /// @@ -65,14 +65,14 @@ public static CompactionTrigger MessagesExceed(int maxMessages) => /// A that evaluates included turn count. /// /// - /// A user turn starts with a group and includes all subsequent + /// A user turn starts with a group and includes all subsequent /// non-user, non-system groups until the next user group or end of conversation. Each group is assigned - /// a indicating which user turn it belongs to. - /// System messages () are always assigned a - /// since they never belong to a user turn. + /// a indicating which user turn it belongs to. + /// System messages () are always assigned a + /// since they never belong to a user turn. /// /// - /// The turn count is the number of distinct values defined by . + /// The turn count is the number of distinct values defined by . /// /// public static CompactionTrigger TurnsExceed(int maxTurns) => @@ -88,11 +88,11 @@ public static CompactionTrigger GroupsExceed(int maxGroups) => /// /// Creates a trigger that fires when the included message index contains at least one - /// non-excluded group. + /// 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); + index => index.Groups.Any(g => !g.IsExcluded && g.Kind == CompactionGroupKind.ToolCall); /// /// Creates a compound trigger that fires only when all of the specified triggers fire. diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/PipelineCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/PipelineCompactionStrategy.cs index 29372f2f9a..0a4c3411b0 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/PipelineCompactionStrategy.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/PipelineCompactionStrategy.cs @@ -12,7 +12,7 @@ namespace Microsoft.Agents.AI.Compaction; /// /// A compaction strategy that executes a sequential pipeline of instances -/// against the same . +/// against the same . /// /// /// @@ -43,7 +43,7 @@ public PipelineCompactionStrategy(params IEnumerable strateg public IReadOnlyList Strategies { get; } /// - protected override async ValueTask CompactCoreAsync(MessageIndex index, ILogger logger, CancellationToken cancellationToken) + protected override async ValueTask CompactCoreAsync(CompactionMessageIndex index, ILogger logger, CancellationToken cancellationToken) { bool anyCompacted = false; diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/SlidingWindowCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/SlidingWindowCompactionStrategy.cs index 6fca61a690..e9c4ea5e72 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/SlidingWindowCompactionStrategy.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/SlidingWindowCompactionStrategy.cs @@ -17,7 +17,7 @@ namespace Microsoft.Agents.AI.Compaction; /// /// /// This strategy always preserves system messages. It identifies user turns in the -/// conversation (via ) and excludes the oldest turns +/// conversation (via ) and excludes the oldest turns /// one at a time until the condition is met. /// /// @@ -66,7 +66,7 @@ public SlidingWindowCompactionStrategy(CompactionTrigger trigger, int minimumPre public int MinimumPreserved { get; } /// - protected override ValueTask CompactCoreAsync(MessageIndex index, ILogger logger, CancellationToken cancellationToken) + protected override ValueTask CompactCoreAsync(CompactionMessageIndex index, ILogger logger, CancellationToken cancellationToken) { // Forward pass: count non-system included groups and pre-index them by TurnIndex. int nonSystemIncludedCount = 0; @@ -75,8 +75,8 @@ protected override ValueTask CompactCoreAsync(MessageIndex index, ILogger for (int i = 0; i < index.Groups.Count; i++) { - MessageGroup group = index.Groups[i]; - if (!group.IsExcluded && group.Kind != MessageGroupKind.System) + CompactionMessageGroup group = index.Groups[i]; + if (!group.IsExcluded && group.Kind != CompactionGroupKind.System) { nonSystemIncludedCount++; @@ -104,8 +104,8 @@ protected override ValueTask CompactCoreAsync(MessageIndex index, ILogger int remaining = protectedCount; for (int i = index.Groups.Count - 1; i >= 0 && remaining > 0; i--) { - MessageGroup group = index.Groups[i]; - if (!group.IsExcluded && group.Kind != MessageGroupKind.System) + CompactionMessageGroup group = index.Groups[i]; + if (!group.IsExcluded && group.Kind != CompactionGroupKind.System) { protectedIndices.Add(i); remaining--; diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/SummarizationCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/SummarizationCompactionStrategy.cs index 27f1d56c42..b1b525aa78 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/SummarizationCompactionStrategy.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/SummarizationCompactionStrategy.cs @@ -22,7 +22,7 @@ namespace Microsoft.Agents.AI.Compaction; /// 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 . +/// with . /// /// /// is a hard floor: even if the @@ -100,14 +100,14 @@ public SummarizationCompactionStrategy( public string SummarizationPrompt { get; } /// - protected override async ValueTask CompactCoreAsync(MessageIndex index, ILogger logger, CancellationToken cancellationToken) + protected override async ValueTask CompactCoreAsync(CompactionMessageIndex index, ILogger logger, CancellationToken cancellationToken) { // Count non-system, non-excluded groups to determine which are protected int nonSystemIncludedCount = 0; for (int i = 0; i < index.Groups.Count; i++) { - MessageGroup group = index.Groups[i]; - if (!group.IsExcluded && group.Kind != MessageGroupKind.System) + CompactionMessageGroup group = index.Groups[i]; + if (!group.IsExcluded && group.Kind != CompactionGroupKind.System) { nonSystemIncludedCount++; } @@ -128,8 +128,8 @@ protected override async ValueTask CompactCoreAsync(MessageIndex index, IL for (int i = 0; i < index.Groups.Count && summarized < maxSummarizable; i++) { - MessageGroup group = index.Groups[i]; - if (group.IsExcluded || group.Kind == MessageGroupKind.System) + CompactionMessageGroup group = index.Groups[i]; + if (group.IsExcluded || group.Kind == CompactionGroupKind.System) { continue; } @@ -169,9 +169,9 @@ protected override async ValueTask CompactCoreAsync(MessageIndex index, IL // Insert a summary group at the position of the first summarized group ChatMessage summaryMessage = new(ChatRole.Assistant, $"[Summary]\n{summaryText}"); - (summaryMessage.AdditionalProperties ??= [])[MessageGroup.SummaryPropertyKey] = true; + (summaryMessage.AdditionalProperties ??= [])[CompactionMessageGroup.SummaryPropertyKey] = true; - index.InsertGroup(insertIndex, MessageGroupKind.Summary, [summaryMessage]); + index.InsertGroup(insertIndex, CompactionGroupKind.Summary, [summaryMessage]); logger.LogSummarizationCompleted(summaryText.Length, insertIndex); diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/ToolResultCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/ToolResultCompactionStrategy.cs index 0c8f8ee2c2..63b140d451 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/ToolResultCompactionStrategy.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/ToolResultCompactionStrategy.cs @@ -17,7 +17,7 @@ namespace Microsoft.Agents.AI.Compaction; /// /// /// This is the gentlest compaction strategy — it does not remove any user messages or -/// plain assistant responses. It only targets +/// 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]. @@ -68,14 +68,14 @@ public ToolResultCompactionStrategy(CompactionTrigger trigger, int minimumPreser public int MinimumPreserved { get; } /// - protected override ValueTask CompactCoreAsync(MessageIndex index, ILogger logger, CancellationToken cancellationToken) + protected override ValueTask CompactCoreAsync(CompactionMessageIndex index, ILogger logger, 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) + CompactionMessageGroup group = index.Groups[i]; + if (!group.IsExcluded && group.Kind != CompactionGroupKind.System) { nonSystemIncludedIndices.Add(i); } @@ -92,8 +92,8 @@ protected override ValueTask CompactCoreAsync(MessageIndex index, ILogger 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)) + CompactionMessageGroup group = index.Groups[i]; + if (!group.IsExcluded && group.Kind == CompactionGroupKind.ToolCall && !protectedGroupIndices.Contains(i)) { eligibleIndices.Add(i); } @@ -111,7 +111,7 @@ protected override ValueTask CompactCoreAsync(MessageIndex index, ILogger for (int e = 0; e < eligibleIndices.Count; e++) { int idx = eligibleIndices[e] + offset; - MessageGroup group = index.Groups[idx]; + CompactionMessageGroup group = index.Groups[idx]; // Extract tool names from FunctionCallContent List toolNames = []; @@ -136,9 +136,9 @@ protected override ValueTask CompactCoreAsync(MessageIndex index, ILogger string summary = $"[Tool calls: {string.Join(", ", toolNames)}]"; ChatMessage summaryMessage = new(ChatRole.Assistant, summary); - (summaryMessage.AdditionalProperties ??= [])[MessageGroup.SummaryPropertyKey] = true; + (summaryMessage.AdditionalProperties ??= [])[CompactionMessageGroup.SummaryPropertyKey] = true; - index.InsertGroup(idx + 1, MessageGroupKind.Summary, [summaryMessage], group.TurnIndex); + index.InsertGroup(idx + 1, CompactionGroupKind.Summary, [summaryMessage], group.TurnIndex); offset++; // Each insertion shifts subsequent indices by 1 compacted = true; diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/TruncationCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/TruncationCompactionStrategy.cs index 2489c0338d..70b4f1e5d3 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/TruncationCompactionStrategy.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/TruncationCompactionStrategy.cs @@ -63,14 +63,14 @@ public TruncationCompactionStrategy(CompactionTrigger trigger, int minimumPreser public int MinimumPreserved { get; } /// - protected override ValueTask CompactCoreAsync(MessageIndex index, ILogger logger, CancellationToken cancellationToken) + protected override ValueTask CompactCoreAsync(CompactionMessageIndex index, ILogger logger, CancellationToken cancellationToken) { // Count removable (non-system, non-excluded) groups int removableCount = 0; for (int i = 0; i < index.Groups.Count; i++) { - MessageGroup group = index.Groups[i]; - if (!group.IsExcluded && group.Kind != MessageGroupKind.System) + CompactionMessageGroup group = index.Groups[i]; + if (!group.IsExcluded && group.Kind != CompactionGroupKind.System) { removableCount++; } @@ -87,8 +87,8 @@ protected override ValueTask CompactCoreAsync(MessageIndex index, ILogger int removed = 0; for (int i = 0; i < index.Groups.Count && removed < maxRemovable; i++) { - MessageGroup group = index.Groups[i]; - if (group.IsExcluded || group.Kind == MessageGroupKind.System) + CompactionMessageGroup group = index.Groups[i]; + if (group.IsExcluded || group.Kind == CompactionGroupKind.System) { continue; } diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/ChatReducerCompactionStrategyTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/ChatReducerCompactionStrategyTests.cs index 07e7dae96e..fb07eeb773 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/ChatReducerCompactionStrategyTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/ChatReducerCompactionStrategyTests.cs @@ -28,7 +28,7 @@ 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( + CompactionMessageIndex index = CompactionMessageIndex.Create( [ new ChatMessage(ChatRole.User, "Hello"), new ChatMessage(ChatRole.Assistant, "Hi!"), @@ -49,7 +49,7 @@ 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( + CompactionMessageIndex index = CompactionMessageIndex.Create( [ new ChatMessage(ChatRole.User, "First"), new ChatMessage(ChatRole.Assistant, "Response 1"), @@ -72,7 +72,7 @@ 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( + CompactionMessageIndex index = CompactionMessageIndex.Create( [ new ChatMessage(ChatRole.User, "Hello"), new ChatMessage(ChatRole.Assistant, "Hi!"), @@ -93,7 +93,7 @@ public async Task CompactAsyncEmptyIndexReturnsFalseAsync() // Arrange — no included messages TestChatReducer reducer = new(messages => messages); ChatReducerCompactionStrategy strategy = new(reducer, CompactionTriggers.Always); - MessageIndex index = MessageIndex.Create([]); + CompactionMessageIndex index = CompactionMessageIndex.Create([]); // Act bool result = await strategy.CompactAsync(index); @@ -115,7 +115,7 @@ public async Task CompactAsyncPreservesSystemMessagesWhenReducerKeepsThemAsync() }); ChatReducerCompactionStrategy strategy = new(reducer, CompactionTriggers.Always); - MessageIndex index = MessageIndex.Create( + CompactionMessageIndex index = CompactionMessageIndex.Create( [ new ChatMessage(ChatRole.System, "You are helpful."), new ChatMessage(ChatRole.User, "First"), @@ -129,9 +129,9 @@ public async Task CompactAsyncPreservesSystemMessagesWhenReducerKeepsThemAsync() // Assert Assert.True(result); Assert.Equal(2, index.IncludedGroupCount); - Assert.Equal(MessageGroupKind.System, index.Groups[0].Kind); + Assert.Equal(CompactionGroupKind.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(CompactionGroupKind.User, index.Groups[1].Kind); Assert.Equal("Second", index.Groups[1].Messages[0].Text); } @@ -145,7 +145,7 @@ public async Task CompactAsyncRebuildsToolCallGroupsCorrectlyAsync() ChatMessage toolResult = new(ChatRole.Tool, "Sunny"); ChatReducerCompactionStrategy strategy = new(reducer, CompactionTriggers.Always); - MessageIndex index = MessageIndex.Create( + CompactionMessageIndex index = CompactionMessageIndex.Create( [ new ChatMessage(ChatRole.User, "Old question"), new ChatMessage(ChatRole.Assistant, "Old answer"), @@ -161,9 +161,9 @@ public async Task CompactAsyncRebuildsToolCallGroupsCorrectlyAsync() 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(CompactionGroupKind.ToolCall, index.Groups[0].Kind); Assert.Equal(2, index.Groups[0].Messages.Count); - Assert.Equal(MessageGroupKind.User, index.Groups[1].Kind); + Assert.Equal(CompactionGroupKind.User, index.Groups[1].Kind); } [Fact] @@ -172,7 +172,7 @@ 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( + CompactionMessageIndex index = CompactionMessageIndex.Create( [ new ChatMessage(ChatRole.User, "Excluded"), new ChatMessage(ChatRole.User, "Included 1"), @@ -214,7 +214,7 @@ public async Task CompactAsyncPassesCancellationTokenToReducerAsync() }); ChatReducerCompactionStrategy strategy = new(reducer, CompactionTriggers.Always); - MessageIndex index = MessageIndex.Create( + CompactionMessageIndex index = CompactionMessageIndex.Create( [ new ChatMessage(ChatRole.User, "First"), new ChatMessage(ChatRole.User, "Second"), diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/MessageIndexTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionMessageIndexTests.cs similarity index 78% rename from dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/MessageIndexTests.cs rename to dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionMessageIndexTests.cs index 6c5c0aec9d..36e690abe4 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/MessageIndexTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionMessageIndexTests.cs @@ -10,9 +10,9 @@ namespace Microsoft.Agents.AI.UnitTests.Compaction; /// -/// Contains tests for the class. +/// Contains tests for the class. /// -public class MessageIndexTests +public class CompactionMessageIndexTests { [Fact] public void CreateEmptyListReturnsEmptyGroups() @@ -21,7 +21,7 @@ public void CreateEmptyListReturnsEmptyGroups() List messages = []; // Act - MessageIndex groups = MessageIndex.Create(messages); + CompactionMessageIndex groups = CompactionMessageIndex.Create(messages); // Assert Assert.Empty(groups.Groups); @@ -37,11 +37,11 @@ public void CreateSystemMessageCreatesSystemGroup() ]; // Act - MessageIndex groups = MessageIndex.Create(messages); + CompactionMessageIndex groups = CompactionMessageIndex.Create(messages); // Assert Assert.Single(groups.Groups); - Assert.Equal(MessageGroupKind.System, groups.Groups[0].Kind); + Assert.Equal(CompactionGroupKind.System, groups.Groups[0].Kind); Assert.Single(groups.Groups[0].Messages); } @@ -55,11 +55,11 @@ public void CreateUserMessageCreatesUserGroup() ]; // Act - MessageIndex groups = MessageIndex.Create(messages); + CompactionMessageIndex groups = CompactionMessageIndex.Create(messages); // Assert Assert.Single(groups.Groups); - Assert.Equal(MessageGroupKind.User, groups.Groups[0].Kind); + Assert.Equal(CompactionGroupKind.User, groups.Groups[0].Kind); } [Fact] @@ -72,11 +72,11 @@ public void CreateAssistantTextMessageCreatesAssistantTextGroup() ]; // Act - MessageIndex groups = MessageIndex.Create(messages); + CompactionMessageIndex groups = CompactionMessageIndex.Create(messages); // Assert Assert.Single(groups.Groups); - Assert.Equal(MessageGroupKind.AssistantText, groups.Groups[0].Kind); + Assert.Equal(CompactionGroupKind.AssistantText, groups.Groups[0].Kind); } [Fact] @@ -89,11 +89,11 @@ public void CreateToolCallWithResultsCreatesAtomicGroup() List messages = [assistantMessage, toolResult]; // Act - MessageIndex groups = MessageIndex.Create(messages); + CompactionMessageIndex groups = CompactionMessageIndex.Create(messages); // Assert Assert.Single(groups.Groups); - Assert.Equal(MessageGroupKind.ToolCall, groups.Groups[0].Kind); + Assert.Equal(CompactionGroupKind.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]); @@ -109,11 +109,11 @@ public void CreateToolCallWithTextCreatesAtomicGroup() List messages = [assistantMessage, toolResult]; // Act - MessageIndex groups = MessageIndex.Create(messages); + CompactionMessageIndex groups = CompactionMessageIndex.Create(messages); // Assert Assert.Single(groups.Groups); - Assert.Equal(MessageGroupKind.ToolCall, groups.Groups[0].Kind); + Assert.Equal(CompactionGroupKind.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]); @@ -132,15 +132,15 @@ public void CreateMixedConversationGroupsCorrectly() List messages = [systemMsg, userMsg, assistantToolCall, toolResult, assistantText]; // Act - MessageIndex groups = MessageIndex.Create(messages); + CompactionMessageIndex groups = CompactionMessageIndex.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(CompactionGroupKind.System, groups.Groups[0].Kind); + Assert.Equal(CompactionGroupKind.User, groups.Groups[1].Kind); + Assert.Equal(CompactionGroupKind.ToolCall, groups.Groups[2].Kind); Assert.Equal(2, groups.Groups[2].Messages.Count); - Assert.Equal(MessageGroupKind.AssistantText, groups.Groups[3].Kind); + Assert.Equal(CompactionGroupKind.AssistantText, groups.Groups[3].Kind); } [Fact] @@ -157,11 +157,11 @@ public void CreateMultipleToolResultsGroupsAllWithAssistant() List messages = [assistantToolCall, toolResult1, toolResult2]; // Act - MessageIndex groups = MessageIndex.Create(messages); + CompactionMessageIndex groups = CompactionMessageIndex.Create(messages); // Assert Assert.Single(groups.Groups); - Assert.Equal(MessageGroupKind.ToolCall, groups.Groups[0].Kind); + Assert.Equal(CompactionGroupKind.ToolCall, groups.Groups[0].Kind); Assert.Equal(3, groups.Groups[0].Messages.Count); } @@ -173,7 +173,7 @@ public void GetIncludedMessagesExcludesMarkedGroups() ChatMessage msg2 = new(ChatRole.Assistant, "Response"); ChatMessage msg3 = new(ChatRole.User, "Second"); - MessageIndex groups = MessageIndex.Create([msg1, msg2, msg3]); + CompactionMessageIndex groups = CompactionMessageIndex.Create([msg1, msg2, msg3]); groups.Groups[1].IsExcluded = true; // Act @@ -192,7 +192,7 @@ public void GetAllMessagesIncludesExcludedGroups() ChatMessage msg1 = new(ChatRole.User, "First"); ChatMessage msg2 = new(ChatRole.Assistant, "Response"); - MessageIndex groups = MessageIndex.Create([msg1, msg2]); + CompactionMessageIndex groups = CompactionMessageIndex.Create([msg1, msg2]); groups.Groups[0].IsExcluded = true; // Act @@ -206,7 +206,7 @@ public void GetAllMessagesIncludesExcludedGroups() public void IncludedGroupCountReflectsExclusions() { // Arrange - MessageIndex groups = MessageIndex.Create( + CompactionMessageIndex groups = CompactionMessageIndex.Create( [ new ChatMessage(ChatRole.User, "A"), new ChatMessage(ChatRole.Assistant, "B"), @@ -225,16 +225,16 @@ public void CreateSummaryMessageCreatesSummaryGroup() { // Arrange ChatMessage summaryMessage = new(ChatRole.Assistant, "[Summary of earlier conversation]: key facts..."); - (summaryMessage.AdditionalProperties ??= [])[MessageGroup.SummaryPropertyKey] = true; + (summaryMessage.AdditionalProperties ??= [])[CompactionMessageGroup.SummaryPropertyKey] = true; List messages = [summaryMessage]; // Act - MessageIndex groups = MessageIndex.Create(messages); + CompactionMessageIndex groups = CompactionMessageIndex.Create(messages); // Assert Assert.Single(groups.Groups); - Assert.Equal(MessageGroupKind.Summary, groups.Groups[0].Kind); + Assert.Equal(CompactionGroupKind.Summary, groups.Groups[0].Kind); Assert.Same(summaryMessage, groups.Groups[0].Messages[0]); } @@ -244,26 +244,26 @@ public void CreateSummaryAmongOtherMessagesGroupsCorrectly() // Arrange ChatMessage systemMsg = new(ChatRole.System, "You are helpful."); ChatMessage summaryMsg = new(ChatRole.Assistant, "[Summary]: previous context"); - (summaryMsg.AdditionalProperties ??= [])[MessageGroup.SummaryPropertyKey] = true; + (summaryMsg.AdditionalProperties ??= [])[CompactionMessageGroup.SummaryPropertyKey] = true; ChatMessage userMsg = new(ChatRole.User, "Continue..."); List messages = [systemMsg, summaryMsg, userMsg]; // Act - MessageIndex groups = MessageIndex.Create(messages); + CompactionMessageIndex groups = CompactionMessageIndex.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); + Assert.Equal(CompactionGroupKind.System, groups.Groups[0].Kind); + Assert.Equal(CompactionGroupKind.Summary, groups.Groups[1].Kind); + Assert.Equal(CompactionGroupKind.User, groups.Groups[2].Kind); } [Fact] public void MessageGroupStoresPassedCounts() { // Arrange & Act - MessageGroup group = new(MessageGroupKind.User, [new ChatMessage(ChatRole.User, "Hello")], byteCount: 5, tokenCount: 2); + CompactionMessageGroup group = new(CompactionGroupKind.User, [new ChatMessage(ChatRole.User, "Hello")], byteCount: 5, tokenCount: 2); // Assert Assert.Equal(1, group.MessageCount); @@ -276,7 +276,7 @@ public void MessageGroupMessagesAreImmutable() { // Arrange IReadOnlyList messages = [new ChatMessage(ChatRole.User, "Hello")]; - MessageGroup group = new(MessageGroupKind.User, messages, byteCount: 5, tokenCount: 1); + CompactionMessageGroup group = new(CompactionGroupKind.User, messages, byteCount: 5, tokenCount: 1); // Assert — Messages is IReadOnlyList, not IList Assert.IsAssignableFrom>(group.Messages); @@ -287,7 +287,7 @@ public void MessageGroupMessagesAreImmutable() public void CreateComputesByteCountUtf8() { // Arrange — "Hello" is 5 UTF-8 bytes - MessageIndex groups = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]); + CompactionMessageIndex groups = CompactionMessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]); // Assert Assert.Equal(5, groups.Groups[0].ByteCount); @@ -297,7 +297,7 @@ public void CreateComputesByteCountUtf8() 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é")]); + CompactionMessageIndex groups = CompactionMessageIndex.Create([new ChatMessage(ChatRole.User, "café")]); // Assert Assert.Equal(5, groups.Groups[0].ByteCount); @@ -309,7 +309,7 @@ public void CreateComputesByteCountMultipleMessagesInGroup() // Arrange — ToolCall group: assistant (tool call) + tool result "OK" (2 bytes) ChatMessage assistantMsg = new(ChatRole.Assistant, [new FunctionCallContent("call1", "fn")]); ChatMessage toolResult = new(ChatRole.Tool, "OK"); - MessageIndex groups = MessageIndex.Create([assistantMsg, toolResult]); + CompactionMessageIndex groups = CompactionMessageIndex.Create([assistantMsg, toolResult]); // Assert — single ToolCall group with 2 messages Assert.Single(groups.Groups); @@ -321,7 +321,7 @@ public void CreateComputesByteCountMultipleMessagesInGroup() 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!")]); + CompactionMessageIndex groups = CompactionMessageIndex.Create([new ChatMessage(ChatRole.User, "Hello world test data!")]); // Assert Assert.Equal(22, groups.Groups[0].ByteCount); @@ -334,7 +334,7 @@ public void CreateNonTextContentHasAccurateCounts() // Arrange — message with pure function call (no text) ChatMessage msg = new(ChatRole.Assistant, [new FunctionCallContent("call1", "get_weather")]); ChatMessage tool = new(ChatRole.Tool, string.Empty); - MessageIndex groups = MessageIndex.Create([msg, tool]); + CompactionMessageIndex groups = CompactionMessageIndex.Create([msg, tool]); // Assert — FunctionCallContent: "call1" (5) + "get_weather" (11) = 16 bytes Assert.Equal(2, groups.Groups[0].MessageCount); @@ -346,7 +346,7 @@ public void CreateNonTextContentHasAccurateCounts() public void TotalAggregatesSumAllGroups() { // Arrange - MessageIndex groups = MessageIndex.Create( + CompactionMessageIndex groups = CompactionMessageIndex.Create( [ new ChatMessage(ChatRole.User, "AAAA"), // 4 bytes new ChatMessage(ChatRole.Assistant, "BBBB"), // 4 bytes @@ -365,7 +365,7 @@ public void TotalAggregatesSumAllGroups() public void IncludedAggregatesExcludeMarkedGroups() { // Arrange - MessageIndex groups = MessageIndex.Create( + CompactionMessageIndex groups = CompactionMessageIndex.Create( [ new ChatMessage(ChatRole.User, "AAAA"), // 4 bytes new ChatMessage(ChatRole.Assistant, "BBBB"), // 4 bytes @@ -392,7 +392,7 @@ public void ToolCallGroupAggregatesAcrossMessages() ChatMessage assistantMsg = new(ChatRole.Assistant, [new FunctionCallContent("call1", "fn")]); ChatMessage toolResult = new(ChatRole.Tool, "OK"); - MessageIndex groups = MessageIndex.Create([assistantMsg, toolResult]); + CompactionMessageIndex groups = CompactionMessageIndex.Create([assistantMsg, toolResult]); // Assert — single group with 2 messages Assert.Single(groups.Groups); @@ -406,7 +406,7 @@ public void ToolCallGroupAggregatesAcrossMessages() public void CreateAssignsTurnIndicesSingleTurn() { // Arrange — System (no turn), User + Assistant = turn 1 - MessageIndex groups = MessageIndex.Create( + CompactionMessageIndex groups = CompactionMessageIndex.Create( [ new ChatMessage(ChatRole.System, "You are helpful."), new ChatMessage(ChatRole.User, "Hello"), @@ -425,7 +425,7 @@ public void CreateAssignsTurnIndicesSingleTurn() public void CreateAssignsTurnIndicesMultiTurn() { // Arrange — 3 user turns - MessageIndex groups = MessageIndex.Create( + CompactionMessageIndex groups = CompactionMessageIndex.Create( [ new ChatMessage(ChatRole.System, "System prompt."), new ChatMessage(ChatRole.User, "Q1"), @@ -452,7 +452,7 @@ public void CreateTurnSpansToolCallGroups() ChatMessage assistantToolCall = new(ChatRole.Assistant, [new FunctionCallContent("call1", "get_weather")]); ChatMessage toolResult = new(ChatRole.Tool, "Sunny"); - MessageIndex groups = MessageIndex.Create( + CompactionMessageIndex groups = CompactionMessageIndex.Create( [ new ChatMessage(ChatRole.User, "What's the weather?"), assistantToolCall, @@ -472,7 +472,7 @@ public void CreateTurnSpansToolCallGroups() public void GetTurnGroupsReturnsGroupsForSpecificTurn() { // Arrange - MessageIndex groups = MessageIndex.Create( + CompactionMessageIndex groups = CompactionMessageIndex.Create( [ new ChatMessage(ChatRole.System, "System."), new ChatMessage(ChatRole.User, "Q1"), @@ -482,23 +482,23 @@ public void GetTurnGroupsReturnsGroupsForSpecificTurn() ]); // Act - List turn1 = [.. groups.GetTurnGroups(1)]; - List turn2 = [.. groups.GetTurnGroups(2)]; + 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(CompactionGroupKind.User, turn1[0].Kind); + Assert.Equal(CompactionGroupKind.AssistantText, turn1[1].Kind); Assert.Equal(2, turn2.Count); - Assert.Equal(MessageGroupKind.User, turn2[0].Kind); - Assert.Equal(MessageGroupKind.AssistantText, turn2[1].Kind); + Assert.Equal(CompactionGroupKind.User, turn2[0].Kind); + Assert.Equal(CompactionGroupKind.AssistantText, turn2[1].Kind); } [Fact] public void IncludedTurnCountReflectsExclusions() { // Arrange — 2 turns, exclude all groups in turn 1 - MessageIndex groups = MessageIndex.Create( + CompactionMessageIndex groups = CompactionMessageIndex.Create( [ new ChatMessage(ChatRole.User, "Q1"), new ChatMessage(ChatRole.Assistant, "A1"), @@ -518,7 +518,7 @@ public void IncludedTurnCountReflectsExclusions() public void TotalTurnCountZeroWhenNoUserMessages() { // Arrange — only system messages - MessageIndex groups = MessageIndex.Create( + CompactionMessageIndex groups = CompactionMessageIndex.Create( [ new ChatMessage(ChatRole.System, "System."), ]); @@ -532,7 +532,7 @@ public void TotalTurnCountZeroWhenNoUserMessages() public void IncludedTurnCountPartialExclusionStillCountsTurn() { // Arrange — turn 1 has 2 groups, only one excluded - MessageIndex groups = MessageIndex.Create( + CompactionMessageIndex groups = CompactionMessageIndex.Create( [ new ChatMessage(ChatRole.User, "Q1"), new ChatMessage(ChatRole.Assistant, "A1"), @@ -554,7 +554,7 @@ public void UpdateAppendsNewMessagesIncrementally() new ChatMessage(ChatRole.User, "Q1"), new ChatMessage(ChatRole.Assistant, "A1"), ]; - MessageIndex index = MessageIndex.Create(messages); + CompactionMessageIndex index = CompactionMessageIndex.Create(messages); Assert.Equal(2, index.Groups.Count); Assert.Equal(2, index.RawMessageCount); @@ -566,8 +566,8 @@ public void UpdateAppendsNewMessagesIncrementally() // Assert — should have 4 groups total, processed count updated Assert.Equal(4, index.Groups.Count); Assert.Equal(4, index.RawMessageCount); - Assert.Equal(MessageGroupKind.User, index.Groups[2].Kind); - Assert.Equal(MessageGroupKind.AssistantText, index.Groups[3].Kind); + Assert.Equal(CompactionGroupKind.User, index.Groups[2].Kind); + Assert.Equal(CompactionGroupKind.AssistantText, index.Groups[3].Kind); } [Fact] @@ -578,7 +578,7 @@ public void UpdateNoOpWhenNoNewMessages() [ new ChatMessage(ChatRole.User, "Q1"), ]; - MessageIndex index = MessageIndex.Create(messages); + CompactionMessageIndex index = CompactionMessageIndex.Create(messages); int originalCount = index.Groups.Count; // Act — update with same count @@ -598,7 +598,7 @@ public void UpdateRebuildsWhenMessagesShrink() new ChatMessage(ChatRole.Assistant, "A1"), new ChatMessage(ChatRole.User, "Q2"), ]; - MessageIndex index = MessageIndex.Create(messages); + CompactionMessageIndex index = CompactionMessageIndex.Create(messages); Assert.Equal(3, index.Groups.Count); // Exclude a group to verify rebuild clears state @@ -626,7 +626,7 @@ public void UpdateWithEmptyListClearsGroups() new ChatMessage(ChatRole.User, "Q1"), new ChatMessage(ChatRole.Assistant, "A1"), ]; - MessageIndex index = MessageIndex.Create(messages); + CompactionMessageIndex index = CompactionMessageIndex.Create(messages); Assert.Equal(2, index.Groups.Count); // Act — update with empty list @@ -647,7 +647,7 @@ public void UpdateRebuildsWhenLastProcessedMessageNotFound() new ChatMessage(ChatRole.User, "Q1"), new ChatMessage(ChatRole.Assistant, "A1"), ]; - MessageIndex index = MessageIndex.Create(messages); + CompactionMessageIndex index = CompactionMessageIndex.Create(messages); Assert.Equal(2, index.Groups.Count); index.Groups[0].IsExcluded = true; @@ -675,7 +675,7 @@ public void UpdatePreservesExistingGroupExclusionState() new ChatMessage(ChatRole.User, "Q1"), new ChatMessage(ChatRole.Assistant, "A1"), ]; - MessageIndex index = MessageIndex.Create(messages); + CompactionMessageIndex index = CompactionMessageIndex.Create(messages); index.Groups[0].IsExcluded = true; index.Groups[0].ExcludeReason = "Test exclusion"; @@ -693,7 +693,7 @@ public void UpdatePreservesExistingGroupExclusionState() public void InsertGroupInsertsAtSpecifiedIndex() { // Arrange - MessageIndex index = MessageIndex.Create( + CompactionMessageIndex index = CompactionMessageIndex.Create( [ new ChatMessage(ChatRole.User, "Q1"), new ChatMessage(ChatRole.User, "Q2"), @@ -701,12 +701,12 @@ public void InsertGroupInsertsAtSpecifiedIndex() // Act — insert between Q1 and Q2 ChatMessage summaryMsg = new(ChatRole.Assistant, "[Summary]"); - MessageGroup inserted = index.InsertGroup(1, MessageGroupKind.Summary, [summaryMsg], turnIndex: 1); + CompactionMessageGroup inserted = index.InsertGroup(1, CompactionGroupKind.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(CompactionGroupKind.Summary, index.Groups[1].Kind); Assert.Equal("[Summary]", index.Groups[1].Messages[0].Text); Assert.Equal(1, inserted.TurnIndex); } @@ -715,14 +715,14 @@ public void InsertGroupInsertsAtSpecifiedIndex() public void AddGroupAppendsToEnd() { // Arrange - MessageIndex index = MessageIndex.Create( + CompactionMessageIndex index = CompactionMessageIndex.Create( [ new ChatMessage(ChatRole.User, "Q1"), ]); // Act ChatMessage msg = new(ChatRole.Assistant, "Appended"); - MessageGroup added = index.AddGroup(MessageGroupKind.AssistantText, [msg], turnIndex: 1); + CompactionMessageGroup added = index.AddGroup(CompactionGroupKind.AssistantText, [msg], turnIndex: 1); // Assert Assert.Equal(2, index.Groups.Count); @@ -734,14 +734,14 @@ public void AddGroupAppendsToEnd() public void InsertGroupComputesByteAndTokenCounts() { // Arrange - MessageIndex index = MessageIndex.Create( + CompactionMessageIndex index = CompactionMessageIndex.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]); + CompactionMessageGroup inserted = index.InsertGroup(0, CompactionGroupKind.AssistantText, [msg]); // Assert Assert.Equal(5, inserted.ByteCount); @@ -752,13 +752,13 @@ public void InsertGroupComputesByteAndTokenCounts() 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]; + CompactionMessageGroup group1 = new(CompactionGroupKind.User, [new ChatMessage(ChatRole.User, "Q1")], 2, 1, turnIndex: 1); + CompactionMessageGroup group2 = new(CompactionGroupKind.AssistantText, [new ChatMessage(ChatRole.Assistant, "A1")], 2, 1, turnIndex: 1); + CompactionMessageGroup group3 = new(CompactionGroupKind.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); + CompactionMessageIndex index = new(groups); // Assert — adding a new user message should get turn 3 (restored 2 + 1) index.Update( @@ -770,8 +770,8 @@ public void ConstructorWithGroupsRestoresTurnIndex() ]); // The new user group should have TurnIndex 3 - MessageGroup lastGroup = index.Groups[index.Groups.Count - 1]; - Assert.Equal(MessageGroupKind.User, lastGroup.Kind); + CompactionMessageGroup lastGroup = index.Groups[index.Groups.Count - 1]; + Assert.Equal(CompactionGroupKind.User, lastGroup.Kind); Assert.NotNull(lastGroup.TurnIndex); } @@ -779,7 +779,7 @@ public void ConstructorWithGroupsRestoresTurnIndex() public void ConstructorWithEmptyGroupsHandlesGracefully() { // Arrange & Act — constructor with empty list - MessageIndex index = new([]); + CompactionMessageIndex index = new([]); // Assert Assert.Empty(index.Groups); @@ -789,11 +789,11 @@ public void ConstructorWithEmptyGroupsHandlesGracefully() 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]; + CompactionMessageGroup systemGroup = new(CompactionGroupKind.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); + CompactionMessageIndex index = new(groups); // Assert Assert.Single(index.Groups); @@ -811,7 +811,7 @@ public void ComputeTokenCountReturnsTokenCount() // Act — use a simple tokenizer that counts words (each word = 1 token) SimpleWordTokenizer tokenizer = new(); - int tokenCount = MessageIndex.ComputeTokenCount(messages, tokenizer); + int tokenCount = CompactionMessageIndex.ComputeTokenCount(messages, tokenizer); // Assert — "Hello world" = 2, "Greetings" = 1 → 3 total Assert.Equal(3, tokenCount); @@ -827,7 +827,7 @@ public void ComputeTokenCountEmptyContentsReturnsZero() ]; SimpleWordTokenizer tokenizer = new(); - int tokenCount = MessageIndex.ComputeTokenCount(messages, tokenizer); + int tokenCount = CompactionMessageIndex.ComputeTokenCount(messages, tokenizer); // Assert — no content → 0 tokens Assert.Equal(0, tokenCount); @@ -845,7 +845,7 @@ public void CreateWithTokenizerUsesTokenizerForCounts() ]; // Act - MessageIndex index = MessageIndex.Create(messages, tokenizer); + CompactionMessageIndex index = CompactionMessageIndex.Create(messages, tokenizer); // Assert — tokenizer counts words: "Hello world test" = 3 tokens Assert.Single(index.Groups); @@ -858,14 +858,14 @@ public void InsertGroupWithTokenizerUsesTokenizer() { // Arrange SimpleWordTokenizer tokenizer = new(); - MessageIndex index = MessageIndex.Create( + CompactionMessageIndex index = CompactionMessageIndex.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]); + CompactionMessageGroup inserted = index.InsertGroup(0, CompactionGroupKind.AssistantText, [msg]); // Assert — tokenizer counts words: "Hello world test message" = 4 tokens Assert.Equal(4, inserted.TokenCount); @@ -880,11 +880,11 @@ public void CreateWithStandaloneToolMessageGroupsAsAssistantText() new ChatMessage(ChatRole.Tool, "Orphaned tool result"), ]; - MessageIndex index = MessageIndex.Create(messages); + CompactionMessageIndex index = CompactionMessageIndex.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); + Assert.Equal(CompactionGroupKind.AssistantText, index.Groups[0].Kind); } [Fact] @@ -894,10 +894,10 @@ public void CreateWithAssistantNonSummaryWithPropertiesFallsToAssistantText() ChatMessage assistant = new(ChatRole.Assistant, "Regular response"); (assistant.AdditionalProperties ??= [])["someOtherKey"] = "value"; - MessageIndex index = MessageIndex.Create([assistant]); + CompactionMessageIndex index = CompactionMessageIndex.Create([assistant]); Assert.Single(index.Groups); - Assert.Equal(MessageGroupKind.AssistantText, index.Groups[0].Kind); + Assert.Equal(CompactionGroupKind.AssistantText, index.Groups[0].Kind); } [Fact] @@ -905,12 +905,12 @@ public void CreateWithSummaryPropertyFalseIsNotSummary() { // Summary property key present but value is false — not a summary ChatMessage assistant = new(ChatRole.Assistant, "Not a summary"); - (assistant.AdditionalProperties ??= [])[MessageGroup.SummaryPropertyKey] = false; + (assistant.AdditionalProperties ??= [])[CompactionMessageGroup.SummaryPropertyKey] = false; - MessageIndex index = MessageIndex.Create([assistant]); + CompactionMessageIndex index = CompactionMessageIndex.Create([assistant]); Assert.Single(index.Groups); - Assert.Equal(MessageGroupKind.AssistantText, index.Groups[0].Kind); + Assert.Equal(CompactionGroupKind.AssistantText, index.Groups[0].Kind); } [Fact] @@ -918,12 +918,12 @@ public void CreateWithSummaryPropertyNonBoolIsNotSummary() { // Summary property key present but value is a string, not a bool ChatMessage assistant = new(ChatRole.Assistant, "Not a summary"); - (assistant.AdditionalProperties ??= [])[MessageGroup.SummaryPropertyKey] = "true"; + (assistant.AdditionalProperties ??= [])[CompactionMessageGroup.SummaryPropertyKey] = "true"; - MessageIndex index = MessageIndex.Create([assistant]); + CompactionMessageIndex index = CompactionMessageIndex.Create([assistant]); Assert.Single(index.Groups); - Assert.Equal(MessageGroupKind.AssistantText, index.Groups[0].Kind); + Assert.Equal(CompactionGroupKind.AssistantText, index.Groups[0].Kind); } [Fact] @@ -931,12 +931,12 @@ public void CreateWithSummaryPropertyNullValueIsNotSummary() { // Summary property key present but value is null ChatMessage assistant = new(ChatRole.Assistant, "Not a summary"); - (assistant.AdditionalProperties ??= [])[MessageGroup.SummaryPropertyKey] = null!; + (assistant.AdditionalProperties ??= [])[CompactionMessageGroup.SummaryPropertyKey] = null!; - MessageIndex index = MessageIndex.Create([assistant]); + CompactionMessageIndex index = CompactionMessageIndex.Create([assistant]); Assert.Single(index.Groups); - Assert.Equal(MessageGroupKind.AssistantText, index.Groups[0].Kind); + Assert.Equal(CompactionGroupKind.AssistantText, index.Groups[0].Kind); } [Fact] @@ -945,10 +945,10 @@ public void CreateWithNoAdditionalPropertiesIsNotSummary() // Assistant message with no AdditionalProperties at all ChatMessage assistant = new(ChatRole.Assistant, "Plain response"); - MessageIndex index = MessageIndex.Create([assistant]); + CompactionMessageIndex index = CompactionMessageIndex.Create([assistant]); Assert.Single(index.Groups); - Assert.Equal(MessageGroupKind.AssistantText, index.Groups[0].Kind); + Assert.Equal(CompactionGroupKind.AssistantText, index.Groups[0].Kind); } [Fact] @@ -961,7 +961,7 @@ public void ComputeByteCountHandlesTextAndNonTextContent() new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "fn")]), ]; - int byteCount = MessageIndex.ComputeByteCount(messages); + int byteCount = CompactionMessageIndex.ComputeByteCount(messages); // "Hello" = 5 bytes, FunctionCallContent("c1", "fn") = "c1" (2) + "fn" (2) = 4 bytes Assert.Equal(9, byteCount); @@ -978,7 +978,7 @@ public void ComputeTokenCountHandlesTextAndNonTextContent() new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "fn")]), ]; - int tokenCount = MessageIndex.ComputeTokenCount(messages, tokenizer); + int tokenCount = CompactionMessageIndex.ComputeTokenCount(messages, tokenizer); // "Hello world" = 2 tokens (tokenized), FunctionCallContent("c1","fn") = 4 bytes → 1 token (estimated) Assert.Equal(3, tokenCount); @@ -992,7 +992,7 @@ public void ComputeByteCountTextContent() new ChatMessage(ChatRole.User, [new TextContent("Hello")]), ]; - Assert.Equal(5, MessageIndex.ComputeByteCount(messages)); + Assert.Equal(5, CompactionMessageIndex.ComputeByteCount(messages)); } [Fact] @@ -1004,7 +1004,7 @@ public void ComputeByteCountTextReasoningContent() ]; // "think" = 5 bytes, "secret" = 6 bytes - Assert.Equal(11, MessageIndex.ComputeByteCount(messages)); + Assert.Equal(11, CompactionMessageIndex.ComputeByteCount(messages)); } [Fact] @@ -1017,7 +1017,7 @@ public void ComputeByteCountDataContent() ]; // 100 (data) + 9 ("image/png") + 3 ("pic") - Assert.Equal(112, MessageIndex.ComputeByteCount(messages)); + Assert.Equal(112, CompactionMessageIndex.ComputeByteCount(messages)); } [Fact] @@ -1029,7 +1029,7 @@ public void ComputeByteCountUriContent() ]; // "https://example.com/image.png" = 29 bytes, "image/png" = 9 bytes - Assert.Equal(38, MessageIndex.ComputeByteCount(messages)); + Assert.Equal(38, CompactionMessageIndex.ComputeByteCount(messages)); } [Fact] @@ -1044,7 +1044,7 @@ public void ComputeByteCountFunctionCallContentWithArguments() ]; // "call1" = 5, "get_weather" = 11, "city" = 4, "Seattle" = 7 - Assert.Equal(27, MessageIndex.ComputeByteCount(messages)); + Assert.Equal(27, CompactionMessageIndex.ComputeByteCount(messages)); } [Fact] @@ -1056,7 +1056,7 @@ public void ComputeByteCountFunctionCallContentWithoutArguments() ]; // "c1" = 2, "fn" = 2 - Assert.Equal(4, MessageIndex.ComputeByteCount(messages)); + Assert.Equal(4, CompactionMessageIndex.ComputeByteCount(messages)); } [Fact] @@ -1068,7 +1068,7 @@ public void ComputeByteCountFunctionResultContent() ]; // "call1" = 5, "Sunny, 72°F" = 13 bytes (° is 2 bytes in UTF-8) - Assert.Equal(5 + System.Text.Encoding.UTF8.GetByteCount("Sunny, 72°F"), MessageIndex.ComputeByteCount(messages)); + Assert.Equal(5 + System.Text.Encoding.UTF8.GetByteCount("Sunny, 72°F"), CompactionMessageIndex.ComputeByteCount(messages)); } [Fact] @@ -1080,7 +1080,7 @@ public void ComputeByteCountErrorContent() ]; // "fail" = 4, "E001" = 4 - Assert.Equal(8, MessageIndex.ComputeByteCount(messages)); + Assert.Equal(8, CompactionMessageIndex.ComputeByteCount(messages)); } [Fact] @@ -1092,7 +1092,7 @@ public void ComputeByteCountHostedFileContent() ]; // "file-abc" = 8, "text/plain" = 10, "readme.txt" = 10 - Assert.Equal(28, MessageIndex.ComputeByteCount(messages)); + Assert.Equal(28, CompactionMessageIndex.ComputeByteCount(messages)); } [Fact] @@ -1109,7 +1109,7 @@ public void ComputeByteCountMixedContentInSingleMessage() // TextContent: "Hello" = 5 bytes // DataContent: 50 (data) + 9 ("image/png") = 59 bytes - Assert.Equal(64, MessageIndex.ComputeByteCount(messages)); + Assert.Equal(64, CompactionMessageIndex.ComputeByteCount(messages)); } [Fact] @@ -1120,7 +1120,7 @@ public void ComputeByteCountEmptyContentsReturnsZero() new ChatMessage(ChatRole.User, []), ]; - Assert.Equal(0, MessageIndex.ComputeByteCount(messages)); + Assert.Equal(0, CompactionMessageIndex.ComputeByteCount(messages)); } [Fact] @@ -1131,7 +1131,7 @@ public void ComputeByteCountUnknownContentTypeReturnsZero() new ChatMessage(ChatRole.Assistant, [new UsageContent(new UsageDetails())]), ]; - Assert.Equal(0, MessageIndex.ComputeByteCount(messages)); + Assert.Equal(0, CompactionMessageIndex.ComputeByteCount(messages)); } [Fact] @@ -1144,7 +1144,7 @@ public void ComputeTokenCountTextReasoningContentUsesTokenizer() ]; // "deep thinking here" = 3 words, "hidden data" = 2 words → 5 tokens via tokenizer - Assert.Equal(5, MessageIndex.ComputeTokenCount(messages, tokenizer)); + Assert.Equal(5, CompactionMessageIndex.ComputeTokenCount(messages, tokenizer)); } [Fact] @@ -1158,7 +1158,7 @@ public void ComputeTokenCountNonTextContentEstimatesFromBytes() ]; // DataContent: 40 (data) + 9 ("image/png") = 49 bytes → 49/4 = 12 tokens (estimated) - Assert.Equal(12, MessageIndex.ComputeTokenCount(messages, tokenizer)); + Assert.Equal(12, CompactionMessageIndex.ComputeTokenCount(messages, tokenizer)); } [Fact] @@ -1176,7 +1176,7 @@ public void ComputeTokenCountMixedTextAndNonTextContent() // TextContent: "Hello world" = 2 tokens (tokenized) // DataContent: 40 + 9 = 49 bytes → 12 tokens (estimated) - Assert.Equal(14, MessageIndex.ComputeTokenCount(messages, tokenizer)); + Assert.Equal(14, CompactionMessageIndex.ComputeTokenCount(messages, tokenizer)); } [Fact] @@ -1187,7 +1187,7 @@ public void CreateGroupByteCountIncludesAllContentTypes() ChatMessage toolResult = new(ChatRole.Tool, [new FunctionResultContent("call1", "Sunny")]); List messages = [assistantMessage, toolResult]; - MessageIndex index = MessageIndex.Create(messages); + CompactionMessageIndex index = CompactionMessageIndex.Create(messages); // ToolCall group: FunctionCallContent("call1","get_weather",{city=Seattle}) + FunctionResultContent("call1","Sunny") // = (5 + 11 + 4 + 7) + (5 + 5) = 27 + 10 = 37 diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionProviderTests.cs index 3f78c0690d..85a0445fa5 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionProviderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionProviderTests.cs @@ -356,7 +356,7 @@ public void CompactionStateAssignment() Assert.Empty(state.MessageGroups); // Act - state.MessageGroups = [new MessageGroup(MessageGroupKind.User, [], 0, 0, 0)]; + state.MessageGroups = [new CompactionMessageGroup(CompactionGroupKind.User, [], 0, 0, 0)]; // Assert Assert.Single(state.MessageGroups); diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionStrategyTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionStrategyTests.cs index 40d9715609..5088c573c3 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionStrategyTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionStrategyTests.cs @@ -26,7 +26,7 @@ public async Task CompactAsyncTriggerNotMetReturnsFalseAsync() { // Arrange — trigger never fires, but enough non-system groups to pass short-circuit TestStrategy strategy = new(_ => false); - MessageIndex index = MessageIndex.Create( + CompactionMessageIndex index = CompactionMessageIndex.Create( [ new ChatMessage(ChatRole.User, "Hello"), new ChatMessage(ChatRole.Assistant, "Hi!"), @@ -45,7 +45,7 @@ public async Task CompactAsyncTriggerMetCallsApplyAsync() { // Arrange — trigger always fires, enough non-system groups TestStrategy strategy = new(_ => true, applyFunc: _ => true); - MessageIndex index = MessageIndex.Create( + CompactionMessageIndex index = CompactionMessageIndex.Create( [ new ChatMessage(ChatRole.User, "Hello"), new ChatMessage(ChatRole.Assistant, "Hi!"), @@ -64,7 +64,7 @@ public async Task CompactAsyncReturnsFalseWhenApplyReturnsFalseAsync() { // Arrange — trigger fires but Apply does nothing TestStrategy strategy = new(_ => true, applyFunc: _ => false); - MessageIndex index = MessageIndex.Create( + CompactionMessageIndex index = CompactionMessageIndex.Create( [ new ChatMessage(ChatRole.User, "Hello"), new ChatMessage(ChatRole.Assistant, "Hi!"), @@ -83,7 +83,7 @@ public async Task CompactAsyncSingleNonSystemGroupShortCircuitsAsync() { // Arrange — trigger would fire, but only 1 non-system group → short-circuit TestStrategy strategy = new(_ => true, applyFunc: _ => true); - MessageIndex index = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]); + CompactionMessageIndex index = CompactionMessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]); // Act bool result = await strategy.CompactAsync(index); @@ -98,7 +98,7 @@ public async Task CompactAsyncSingleNonSystemGroupWithSystemShortCircuitsAsync() { // Arrange — system group + 1 non-system group → still short-circuits TestStrategy strategy = new(_ => true, applyFunc: _ => true); - MessageIndex index = MessageIndex.Create( + CompactionMessageIndex index = CompactionMessageIndex.Create( [ new ChatMessage(ChatRole.System, "You are helpful."), new ChatMessage(ChatRole.User, "Hello"), @@ -117,7 +117,7 @@ public async Task CompactAsyncTwoNonSystemGroupsProceedsToTriggerAsync() { // Arrange — exactly 2 non-system groups: boundary passes, trigger fires TestStrategy strategy = new(_ => true, applyFunc: _ => true); - MessageIndex index = MessageIndex.Create( + CompactionMessageIndex index = CompactionMessageIndex.Create( [ new ChatMessage(ChatRole.User, "Hello"), new ChatMessage(ChatRole.Assistant, "Hi!"), @@ -140,9 +140,9 @@ public async Task CompactAsyncDefaultTargetIsInverseOfTriggerAsync() TestStrategy strategy = new(trigger, applyFunc: index => { // Exclude oldest non-system group one at a time - foreach (MessageGroup group in index.Groups) + foreach (CompactionMessageGroup group in index.Groups) { - if (!group.IsExcluded && group.Kind != MessageGroupKind.System) + if (!group.IsExcluded && group.Kind != CompactionGroupKind.System) { group.IsExcluded = true; // Target (default = !trigger) returns true when groups <= 2 @@ -154,7 +154,7 @@ public async Task CompactAsyncDefaultTargetIsInverseOfTriggerAsync() return true; }); - MessageIndex index = MessageIndex.Create( + CompactionMessageIndex index = CompactionMessageIndex.Create( [ new ChatMessage(ChatRole.User, "Q1"), new ChatMessage(ChatRole.Assistant, "A1"), @@ -175,7 +175,7 @@ public async Task CompactAsyncCustomTargetIsPassedToStrategyAsync() { // Arrange — custom target that always signals stop bool targetCalled = false; - bool CustomTarget(MessageIndex _) + bool CustomTarget(CompactionMessageIndex _) { targetCalled = true; return true; @@ -187,7 +187,7 @@ bool CustomTarget(MessageIndex _) return true; }); - MessageIndex index = MessageIndex.Create( + CompactionMessageIndex index = CompactionMessageIndex.Create( [ new ChatMessage(ChatRole.User, "Hello"), new ChatMessage(ChatRole.Assistant, "Hi!"), @@ -208,12 +208,12 @@ bool CustomTarget(MessageIndex _) /// private sealed class TestStrategy : CompactionStrategy { - private readonly Func? _applyFunc; + private readonly Func? _applyFunc; public TestStrategy( CompactionTrigger trigger, CompactionTrigger? target = null, - Func? applyFunc = null) + Func? applyFunc = null) : base(trigger, target) { this._applyFunc = applyFunc; @@ -224,9 +224,9 @@ public TestStrategy( /// /// Exposes the protected Target property for test verification. /// - public bool InvokeTarget(MessageIndex index) => this.Target(index); + public bool InvokeTarget(CompactionMessageIndex index) => this.Target(index); - protected override ValueTask CompactCoreAsync(MessageIndex index, ILogger logger, CancellationToken cancellationToken) + protected override ValueTask CompactCoreAsync(CompactionMessageIndex index, ILogger logger, CancellationToken cancellationToken) { this.ApplyCallCount++; bool result = this._applyFunc?.Invoke(index) ?? false; diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionTriggersTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionTriggersTests.cs index be3a874459..e057496e2b 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionTriggersTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionTriggersTests.cs @@ -15,7 +15,7 @@ public void TokensExceedReturnsTrueWhenAboveThreshold() { // Arrange — use a long message to guarantee tokens > 0 CompactionTrigger trigger = CompactionTriggers.TokensExceed(0); - MessageIndex index = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello world")]); + CompactionMessageIndex index = CompactionMessageIndex.Create([new ChatMessage(ChatRole.User, "Hello world")]); // Act & Assert Assert.True(trigger(index)); @@ -25,7 +25,7 @@ public void TokensExceedReturnsTrueWhenAboveThreshold() public void TokensExceedReturnsFalseWhenBelowThreshold() { CompactionTrigger trigger = CompactionTriggers.TokensExceed(999_999); - MessageIndex index = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hi")]); + CompactionMessageIndex index = CompactionMessageIndex.Create([new ChatMessage(ChatRole.User, "Hi")]); Assert.False(trigger(index)); } @@ -34,12 +34,12 @@ public void TokensExceedReturnsFalseWhenBelowThreshold() public void MessagesExceedReturnsExpectedResult() { CompactionTrigger trigger = CompactionTriggers.MessagesExceed(2); - MessageIndex small = MessageIndex.Create( + CompactionMessageIndex small = CompactionMessageIndex.Create( [ new ChatMessage(ChatRole.User, "A"), new ChatMessage(ChatRole.User, "B"), ]); - MessageIndex large = MessageIndex.Create( + CompactionMessageIndex large = CompactionMessageIndex.Create( [ new ChatMessage(ChatRole.User, "A"), new ChatMessage(ChatRole.User, "B"), @@ -54,12 +54,12 @@ public void MessagesExceedReturnsExpectedResult() public void TurnsExceedReturnsExpectedResult() { CompactionTrigger trigger = CompactionTriggers.TurnsExceed(1); - MessageIndex oneTurn = MessageIndex.Create( + CompactionMessageIndex oneTurn = CompactionMessageIndex.Create( [ new ChatMessage(ChatRole.User, "Q1"), new ChatMessage(ChatRole.Assistant, "A1"), ]); - MessageIndex twoTurns = MessageIndex.Create( + CompactionMessageIndex twoTurns = CompactionMessageIndex.Create( [ new ChatMessage(ChatRole.User, "Q1"), new ChatMessage(ChatRole.Assistant, "A1"), @@ -74,7 +74,7 @@ public void TurnsExceedReturnsExpectedResult() public void GroupsExceedReturnsExpectedResult() { CompactionTrigger trigger = CompactionTriggers.GroupsExceed(2); - MessageIndex index = MessageIndex.Create( + CompactionMessageIndex index = CompactionMessageIndex.Create( [ new ChatMessage(ChatRole.User, "A"), new ChatMessage(ChatRole.Assistant, "B"), @@ -88,7 +88,7 @@ public void GroupsExceedReturnsExpectedResult() public void HasToolCallsReturnsTrueWhenToolCallGroupExists() { CompactionTrigger trigger = CompactionTriggers.HasToolCalls(); - MessageIndex index = MessageIndex.Create( + CompactionMessageIndex index = CompactionMessageIndex.Create( [ new ChatMessage(ChatRole.User, "Q1"), new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "fn")]), @@ -102,7 +102,7 @@ public void HasToolCallsReturnsTrueWhenToolCallGroupExists() public void HasToolCallsReturnsFalseWhenNoToolCallGroup() { CompactionTrigger trigger = CompactionTriggers.HasToolCalls(); - MessageIndex index = MessageIndex.Create( + CompactionMessageIndex index = CompactionMessageIndex.Create( [ new ChatMessage(ChatRole.User, "Hello"), new ChatMessage(ChatRole.Assistant, "Hi!"), @@ -118,7 +118,7 @@ public void AllRequiresAllConditions() CompactionTriggers.TokensExceed(0), CompactionTriggers.MessagesExceed(5)); - MessageIndex small = MessageIndex.Create([new ChatMessage(ChatRole.User, "A")]); + CompactionMessageIndex small = CompactionMessageIndex.Create([new ChatMessage(ChatRole.User, "A")]); // Tokens > 0 is true, but messages > 5 is false Assert.False(trigger(small)); @@ -131,7 +131,7 @@ public void AnyRequiresAtLeastOneCondition() CompactionTriggers.TokensExceed(999_999), CompactionTriggers.MessagesExceed(0)); - MessageIndex index = MessageIndex.Create([new ChatMessage(ChatRole.User, "A")]); + CompactionMessageIndex index = CompactionMessageIndex.Create([new ChatMessage(ChatRole.User, "A")]); // Tokens not exceeded, but messages > 0 is true Assert.True(trigger(index)); @@ -141,7 +141,7 @@ public void AnyRequiresAtLeastOneCondition() public void AllEmptyTriggersReturnsTrue() { CompactionTrigger trigger = CompactionTriggers.All(); - MessageIndex index = MessageIndex.Create([new ChatMessage(ChatRole.User, "A")]); + CompactionMessageIndex index = CompactionMessageIndex.Create([new ChatMessage(ChatRole.User, "A")]); Assert.True(trigger(index)); } @@ -149,7 +149,7 @@ public void AllEmptyTriggersReturnsTrue() public void AnyEmptyTriggersReturnsFalse() { CompactionTrigger trigger = CompactionTriggers.Any(); - MessageIndex index = MessageIndex.Create([new ChatMessage(ChatRole.User, "A")]); + CompactionMessageIndex index = CompactionMessageIndex.Create([new ChatMessage(ChatRole.User, "A")]); Assert.False(trigger(index)); } @@ -157,7 +157,7 @@ public void AnyEmptyTriggersReturnsFalse() public void TokensBelowReturnsTrueWhenBelowThreshold() { CompactionTrigger trigger = CompactionTriggers.TokensBelow(999_999); - MessageIndex index = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hi")]); + CompactionMessageIndex index = CompactionMessageIndex.Create([new ChatMessage(ChatRole.User, "Hi")]); Assert.True(trigger(index)); } @@ -166,7 +166,7 @@ public void TokensBelowReturnsTrueWhenBelowThreshold() public void TokensBelowReturnsFalseWhenAboveThreshold() { CompactionTrigger trigger = CompactionTriggers.TokensBelow(0); - MessageIndex index = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello world")]); + CompactionMessageIndex index = CompactionMessageIndex.Create([new ChatMessage(ChatRole.User, "Hello world")]); Assert.False(trigger(index)); } @@ -174,7 +174,7 @@ public void TokensBelowReturnsFalseWhenAboveThreshold() [Fact] public void AlwaysReturnsTrue() { - MessageIndex index = MessageIndex.Create([new ChatMessage(ChatRole.User, "A")]); + CompactionMessageIndex index = CompactionMessageIndex.Create([new ChatMessage(ChatRole.User, "A")]); Assert.True(CompactionTriggers.Always(index)); } } diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/PipelineCompactionStrategyTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/PipelineCompactionStrategyTests.cs index a648085e65..3d1a7d8dfb 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/PipelineCompactionStrategyTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/PipelineCompactionStrategyTests.cs @@ -35,7 +35,7 @@ public async Task CompactAsyncExecutesAllStrategiesInOrderAsync() }); PipelineCompactionStrategy pipeline = new(strategy1, strategy2); - MessageIndex groups = MessageIndex.Create( + CompactionMessageIndex groups = CompactionMessageIndex.Create( [ new ChatMessage(ChatRole.User, "Hello"), new ChatMessage(ChatRole.Assistant, "Hi!"), @@ -55,7 +55,7 @@ public async Task CompactAsyncReturnsFalseWhenNoStrategyCompactsAsync() TestCompactionStrategy strategy1 = new(_ => false); PipelineCompactionStrategy pipeline = new(strategy1); - MessageIndex groups = MessageIndex.Create( + CompactionMessageIndex groups = CompactionMessageIndex.Create( [ new ChatMessage(ChatRole.User, "Hello"), new ChatMessage(ChatRole.Assistant, "Hi!"), @@ -76,7 +76,7 @@ public async Task CompactAsyncReturnsTrueWhenAnyStrategyCompactsAsync() TestCompactionStrategy strategy2 = new(_ => true); PipelineCompactionStrategy pipeline = new(strategy1, strategy2); - MessageIndex groups = MessageIndex.Create( + CompactionMessageIndex groups = CompactionMessageIndex.Create( [ new ChatMessage(ChatRole.User, "Hello"), new ChatMessage(ChatRole.Assistant, "Hi!"), @@ -97,7 +97,7 @@ public async Task CompactAsyncContinuesAfterFirstCompactionAsync() TestCompactionStrategy strategy2 = new(_ => false); PipelineCompactionStrategy pipeline = new(strategy1, strategy2); - MessageIndex groups = MessageIndex.Create( + CompactionMessageIndex groups = CompactionMessageIndex.Create( [ new ChatMessage(ChatRole.User, "Hello"), new ChatMessage(ChatRole.Assistant, "Hi!"), @@ -115,12 +115,12 @@ public async Task CompactAsyncContinuesAfterFirstCompactionAsync() public async Task CompactAsyncComposesStrategiesEndToEndAsync() { // Arrange — pipeline: first exclude oldest 2 non-system groups, then exclude 2 more - static void ExcludeOldest2(MessageIndex index) + static void ExcludeOldest2(CompactionMessageIndex index) { int excluded = 0; - foreach (MessageGroup group in index.Groups) + foreach (CompactionMessageGroup group in index.Groups) { - if (!group.IsExcluded && group.Kind != MessageGroupKind.System && excluded < 2) + if (!group.IsExcluded && group.Kind != CompactionGroupKind.System && excluded < 2) { group.IsExcluded = true; excluded++; @@ -144,7 +144,7 @@ static void ExcludeOldest2(MessageIndex index) PipelineCompactionStrategy pipeline = new(phase1, phase2); - MessageIndex groups = MessageIndex.Create( + CompactionMessageIndex groups = CompactionMessageIndex.Create( [ new ChatMessage(ChatRole.System, "You are helpful."), new ChatMessage(ChatRole.User, "Q1"), @@ -175,7 +175,7 @@ public async Task CompactAsyncEmptyPipelineReturnsFalseAsync() { // Arrange PipelineCompactionStrategy pipeline = new(new List()); - MessageIndex groups = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]); + CompactionMessageIndex groups = CompactionMessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]); // Act bool result = await pipeline.CompactAsync(groups); @@ -189,9 +189,9 @@ public async Task CompactAsyncEmptyPipelineReturnsFalseAsync() /// private sealed class TestCompactionStrategy : CompactionStrategy { - private readonly Func _applyFunc; + private readonly Func _applyFunc; - public TestCompactionStrategy(Func applyFunc) + public TestCompactionStrategy(Func applyFunc) : base(CompactionTriggers.Always) { this._applyFunc = applyFunc; @@ -199,7 +199,7 @@ public TestCompactionStrategy(Func applyFunc) public int ApplyCallCount { get; private set; } - protected override ValueTask CompactCoreAsync(MessageIndex index, ILogger logger, CancellationToken cancellationToken) + protected override ValueTask CompactCoreAsync(CompactionMessageIndex index, ILogger logger, CancellationToken cancellationToken) { this.ApplyCallCount++; return new(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 index 709a44e0a4..ec7753b131 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/SlidingWindowCompactionStrategyTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/SlidingWindowCompactionStrategyTests.cs @@ -17,7 +17,7 @@ public async Task CompactAsyncBelowMaxTurnsReturnsFalseAsync() { // Arrange — trigger requires > 3 turns, conversation has 2 SlidingWindowCompactionStrategy strategy = new(CompactionTriggers.TurnsExceed(3)); - MessageIndex groups = MessageIndex.Create( + CompactionMessageIndex groups = CompactionMessageIndex.Create( [ new ChatMessage(ChatRole.User, "Q1"), new ChatMessage(ChatRole.Assistant, "A1"), @@ -37,7 +37,7 @@ public async Task CompactAsyncExceedsMaxTurnsExcludesOldestTurnsAsync() { // Arrange — trigger on > 2 turns, conversation has 3 SlidingWindowCompactionStrategy strategy = new(CompactionTriggers.TurnsExceed(2)); - MessageIndex groups = MessageIndex.Create( + CompactionMessageIndex groups = CompactionMessageIndex.Create( [ new ChatMessage(ChatRole.User, "Q1"), new ChatMessage(ChatRole.Assistant, "A1"), @@ -67,7 +67,7 @@ public async Task CompactAsyncPreservesSystemMessagesAsync() { // Arrange — trigger on > 1 turn SlidingWindowCompactionStrategy strategy = new(CompactionTriggers.TurnsExceed(1)); - MessageIndex groups = MessageIndex.Create( + CompactionMessageIndex groups = CompactionMessageIndex.Create( [ new ChatMessage(ChatRole.System, "You are helpful."), new ChatMessage(ChatRole.User, "Q1"), @@ -91,7 +91,7 @@ public async Task CompactAsyncPreservesToolCallGroupsInKeptTurnsAsync() { // Arrange — trigger on > 1 turn SlidingWindowCompactionStrategy strategy = new(CompactionTriggers.TurnsExceed(1)); - MessageIndex groups = MessageIndex.Create( + CompactionMessageIndex groups = CompactionMessageIndex.Create( [ new ChatMessage(ChatRole.User, "Q1"), new ChatMessage(ChatRole.Assistant, "A1"), @@ -119,7 +119,7 @@ public async Task CompactAsyncTriggerNotMetReturnsFalseAsync() // Arrange — trigger requires > 99 turns SlidingWindowCompactionStrategy strategy = new(CompactionTriggers.TurnsExceed(99)); - MessageIndex groups = MessageIndex.Create( + CompactionMessageIndex groups = CompactionMessageIndex.Create( [ new ChatMessage(ChatRole.User, "Q1"), new ChatMessage(ChatRole.User, "Q2"), @@ -138,7 +138,7 @@ public async Task CompactAsyncIncludedMessagesContainOnlyKeptTurnsAsync() { // Arrange — trigger on > 1 turn SlidingWindowCompactionStrategy strategy = new(CompactionTriggers.TurnsExceed(1)); - MessageIndex groups = MessageIndex.Create( + CompactionMessageIndex groups = CompactionMessageIndex.Create( [ new ChatMessage(ChatRole.System, "System"), new ChatMessage(ChatRole.User, "Q1"), @@ -163,14 +163,14 @@ public async Task CompactAsyncCustomTargetStopsExcludingEarlyAsync() { // Arrange — trigger on > 1 turn, custom target stops after removing 1 turn int removeCount = 0; - bool TargetAfterOne(MessageIndex _) => ++removeCount >= 1; + bool TargetAfterOne(CompactionMessageIndex _) => ++removeCount >= 1; SlidingWindowCompactionStrategy strategy = new( CompactionTriggers.TurnsExceed(1), minimumPreserved: 0, target: TargetAfterOne); - MessageIndex index = MessageIndex.Create( + CompactionMessageIndex index = CompactionMessageIndex.Create( [ new ChatMessage(ChatRole.User, "Q1"), new ChatMessage(ChatRole.Assistant, "A1"), @@ -201,7 +201,7 @@ public async Task CompactAsyncMinimumPreservedStopsCompactionAsync() minimumPreserved: 2, target: _ => false); - MessageIndex index = MessageIndex.Create( + CompactionMessageIndex index = CompactionMessageIndex.Create( [ new ChatMessage(ChatRole.User, "Q1"), new ChatMessage(ChatRole.Assistant, "A1"), @@ -230,7 +230,7 @@ public async Task CompactAsyncSkipsExcludedAndSystemGroupsInEnumerationAsync() CompactionTriggers.TurnsExceed(1), minimumPreserved: 0); - MessageIndex index = MessageIndex.Create( + CompactionMessageIndex index = CompactionMessageIndex.Create( [ new ChatMessage(ChatRole.System, "System prompt"), new ChatMessage(ChatRole.User, "Q1"), diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/SummarizationCompactionStrategyTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/SummarizationCompactionStrategyTests.cs index 9d2f4b25b2..266a84df8a 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() CompactionTriggers.TokensExceed(100000), minimumPreserved: 1); - MessageIndex index = MessageIndex.Create( + CompactionMessageIndex index = CompactionMessageIndex.Create( [ new ChatMessage(ChatRole.User, "Hello"), new ChatMessage(ChatRole.Assistant, "Hi!"), @@ -61,7 +61,7 @@ public async Task CompactAsyncSummarizesOldGroupsAsync() CompactionTriggers.Always, minimumPreserved: 1); - MessageIndex index = MessageIndex.Create( + CompactionMessageIndex index = CompactionMessageIndex.Create( [ new ChatMessage(ChatRole.User, "First question"), new ChatMessage(ChatRole.Assistant, "First answer"), @@ -92,7 +92,7 @@ public async Task CompactAsyncPreservesSystemMessagesAsync() CompactionTriggers.Always, minimumPreserved: 1); - MessageIndex index = MessageIndex.Create( + CompactionMessageIndex index = CompactionMessageIndex.Create( [ new ChatMessage(ChatRole.System, "You are helpful."), new ChatMessage(ChatRole.User, "Old question"), @@ -119,7 +119,7 @@ public async Task CompactAsyncInsertsSummaryGroupAtCorrectPositionAsync() CompactionTriggers.Always, minimumPreserved: 1); - MessageIndex index = MessageIndex.Create( + CompactionMessageIndex index = CompactionMessageIndex.Create( [ new ChatMessage(ChatRole.System, "System prompt."), new ChatMessage(ChatRole.User, "Q1"), @@ -131,10 +131,10 @@ public async Task CompactAsyncInsertsSummaryGroupAtCorrectPositionAsync() await strategy.CompactAsync(index); // Assert — summary should be inserted after system, before preserved group - MessageGroup summaryGroup = index.Groups.First(g => g.Kind == MessageGroupKind.Summary); + CompactionMessageGroup summaryGroup = index.Groups.First(g => g.Kind == CompactionGroupKind.Summary); Assert.NotNull(summaryGroup); Assert.Contains("[Summary]", summaryGroup.Messages[0].Text); - Assert.True(summaryGroup.Messages[0].AdditionalProperties!.ContainsKey(MessageGroup.SummaryPropertyKey)); + Assert.True(summaryGroup.Messages[0].AdditionalProperties!.ContainsKey(CompactionMessageGroup.SummaryPropertyKey)); } [Fact] @@ -146,7 +146,7 @@ public async Task CompactAsyncHandlesEmptyLlmResponseAsync() CompactionTriggers.Always, minimumPreserved: 1); - MessageIndex index = MessageIndex.Create( + CompactionMessageIndex index = CompactionMessageIndex.Create( [ new ChatMessage(ChatRole.User, "Q1"), new ChatMessage(ChatRole.User, "Q2"), @@ -169,7 +169,7 @@ public async Task CompactAsyncNothingToSummarizeReturnsFalseAsync() CompactionTriggers.Always, minimumPreserved: 5); - MessageIndex index = MessageIndex.Create( + CompactionMessageIndex index = CompactionMessageIndex.Create( [ new ChatMessage(ChatRole.User, "Hello"), new ChatMessage(ChatRole.Assistant, "Hi!"), @@ -203,7 +203,7 @@ public async Task CompactAsyncUsesCustomPromptAsync() minimumPreserved: 1, summarizationPrompt: CustomPrompt); - MessageIndex index = MessageIndex.Create( + CompactionMessageIndex index = CompactionMessageIndex.Create( [ new ChatMessage(ChatRole.User, "Q1"), new ChatMessage(ChatRole.User, "Q2"), @@ -230,7 +230,7 @@ public async Task CompactAsyncSetsExcludeReasonAsync() CompactionTriggers.Always, minimumPreserved: 1); - MessageIndex index = MessageIndex.Create( + CompactionMessageIndex index = CompactionMessageIndex.Create( [ new ChatMessage(ChatRole.User, "Old"), new ChatMessage(ChatRole.User, "New"), @@ -240,7 +240,7 @@ public async Task CompactAsyncSetsExcludeReasonAsync() await strategy.CompactAsync(index); // Assert - MessageGroup excluded = index.Groups.First(g => g.IsExcluded); + CompactionMessageGroup excluded = index.Groups.First(g => g.IsExcluded); Assert.NotNull(excluded.ExcludeReason); Assert.Contains("SummarizationCompactionStrategy", excluded.ExcludeReason); } @@ -250,7 +250,7 @@ public async Task CompactAsyncTargetStopsMarkingEarlyAsync() { // Arrange — 4 non-system groups, preserve 1, target met after 1 exclusion int exclusionCount = 0; - bool TargetAfterOne(MessageIndex _) => ++exclusionCount >= 1; + bool TargetAfterOne(CompactionMessageIndex _) => ++exclusionCount >= 1; SummarizationCompactionStrategy strategy = new( CreateMockChatClient("Partial summary."), @@ -258,7 +258,7 @@ public async Task CompactAsyncTargetStopsMarkingEarlyAsync() minimumPreserved: 1, target: TargetAfterOne); - MessageIndex index = MessageIndex.Create( + CompactionMessageIndex index = CompactionMessageIndex.Create( [ new ChatMessage(ChatRole.User, "Q1"), new ChatMessage(ChatRole.Assistant, "A1"), @@ -283,7 +283,7 @@ public async Task CompactAsyncPreservesMultipleRecentGroupsAsync() CompactionTriggers.Always, minimumPreserved: 2); - MessageIndex index = MessageIndex.Create( + CompactionMessageIndex index = CompactionMessageIndex.Create( [ new ChatMessage(ChatRole.User, "Q1"), new ChatMessage(ChatRole.Assistant, "A1"), @@ -312,7 +312,7 @@ public async Task CompactAsyncWithSystemBetweenSummarizableGroupsAsync() CompactionTriggers.Always, minimumPreserved: 1); - MessageIndex index = MessageIndex.Create( + CompactionMessageIndex index = CompactionMessageIndex.Create( [ new ChatMessage(ChatRole.User, "Q1"), new ChatMessage(ChatRole.System, "System note"), @@ -325,8 +325,8 @@ public async Task CompactAsyncWithSystemBetweenSummarizableGroupsAsync() // 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.Equal(CompactionGroupKind.Summary, index.Groups[0].Kind); + Assert.Equal(CompactionGroupKind.System, index.Groups[2].Kind); Assert.False(index.Groups[2].IsExcluded); // System never excluded } @@ -341,7 +341,7 @@ public async Task CompactAsyncMaxSummarizableBoundsLoopExitAsync() minimumPreserved: 3, target: _ => false); - MessageIndex index = MessageIndex.Create( + CompactionMessageIndex index = CompactionMessageIndex.Create( [ new ChatMessage(ChatRole.User, "Q1"), new ChatMessage(ChatRole.Assistant, "A1"), @@ -369,7 +369,7 @@ public async Task CompactAsyncWithPreExcludedGroupAsync() CompactionTriggers.Always, minimumPreserved: 1); - MessageIndex index = MessageIndex.Create( + CompactionMessageIndex index = CompactionMessageIndex.Create( [ new ChatMessage(ChatRole.User, "Q1"), new ChatMessage(ChatRole.Assistant, "A1"), @@ -403,7 +403,7 @@ public async Task CompactAsyncWithEmptyTextMessageInGroupAsync() new ChatMessage(ChatRole.Assistant, "A1"), ]; - MessageIndex index = MessageIndex.Create(messages); + CompactionMessageIndex index = CompactionMessageIndex.Create(messages); // Act — the tool-call group's message has null text bool result = await strategy.CompactAsync(index); diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/ToolResultCompactionStrategyTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/ToolResultCompactionStrategyTests.cs index 346d8d2fb3..8ee39615c1 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/ToolResultCompactionStrategyTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/ToolResultCompactionStrategyTests.cs @@ -21,7 +21,7 @@ public async Task CompactAsyncTriggerNotMetReturnsFalseAsync() ChatMessage toolCall = new(ChatRole.Assistant, [new FunctionCallContent("call1", "get_weather")]); ChatMessage toolResult = new(ChatRole.Tool, "Sunny"); - MessageIndex groups = MessageIndex.Create( + CompactionMessageIndex groups = CompactionMessageIndex.Create( [ new ChatMessage(ChatRole.User, "What's the weather?"), toolCall, @@ -43,7 +43,7 @@ public async Task CompactAsyncCollapsesOldToolGroupsAsync() trigger: _ => true, minimumPreserved: 1); - MessageIndex groups = MessageIndex.Create( + CompactionMessageIndex groups = CompactionMessageIndex.Create( [ new ChatMessage(ChatRole.User, "Q1"), new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", "get_weather")]), @@ -73,7 +73,7 @@ public async Task CompactAsyncPreservesRecentToolGroupsAsync() trigger: _ => true, minimumPreserved: 3); - MessageIndex groups = MessageIndex.Create( + CompactionMessageIndex groups = CompactionMessageIndex.Create( [ new ChatMessage(ChatRole.User, "Q1"), new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", "search")]), @@ -96,7 +96,7 @@ public async Task CompactAsyncPreservesSystemMessagesAsync() trigger: _ => true, minimumPreserved: 1); - MessageIndex groups = MessageIndex.Create( + CompactionMessageIndex groups = CompactionMessageIndex.Create( [ new ChatMessage(ChatRole.System, "You are helpful."), new ChatMessage(ChatRole.User, "Q1"), @@ -127,7 +127,7 @@ public async Task CompactAsyncExtractsMultipleToolNamesAsync() new FunctionCallContent("c2", "search_docs"), ]); - MessageIndex groups = MessageIndex.Create( + CompactionMessageIndex groups = CompactionMessageIndex.Create( [ new ChatMessage(ChatRole.User, "Q1"), multiToolCall, @@ -154,7 +154,7 @@ public async Task CompactAsyncNoToolGroupsReturnsFalseAsync() trigger: _ => true, minimumPreserved: 0); - MessageIndex groups = MessageIndex.Create( + CompactionMessageIndex groups = CompactionMessageIndex.Create( [ new ChatMessage(ChatRole.User, "Hello"), new ChatMessage(ChatRole.Assistant, "Hi!"), @@ -177,7 +177,7 @@ public async Task CompactAsyncCompoundTriggerRequiresTokensAndToolCallsAsync() CompactionTriggers.HasToolCalls()), minimumPreserved: 1); - MessageIndex groups = MessageIndex.Create( + CompactionMessageIndex groups = CompactionMessageIndex.Create( [ new ChatMessage(ChatRole.User, "Q1"), new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "fn")]), @@ -197,14 +197,14 @@ public async Task CompactAsyncTargetStopsCollapsingEarlyAsync() { // Arrange — 2 tool groups, target met after first collapse int collapseCount = 0; - bool TargetAfterOne(MessageIndex _) => ++collapseCount >= 1; + bool TargetAfterOne(CompactionMessageIndex _) => ++collapseCount >= 1; ToolResultCompactionStrategy strategy = new( trigger: _ => true, minimumPreserved: 1, target: TargetAfterOne); - MessageIndex index = MessageIndex.Create( + CompactionMessageIndex index = CompactionMessageIndex.Create( [ new ChatMessage(ChatRole.User, "Q1"), new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "fn1")]), @@ -223,9 +223,9 @@ public async Task CompactAsyncTargetStopsCollapsingEarlyAsync() // Count collapsed tool groups (excluded with ToolCall kind) int collapsedToolGroups = 0; - foreach (MessageGroup group in index.Groups) + foreach (CompactionMessageGroup group in index.Groups) { - if (group.IsExcluded && group.Kind == MessageGroupKind.ToolCall) + if (group.IsExcluded && group.Kind == CompactionGroupKind.ToolCall) { collapsedToolGroups++; } @@ -249,7 +249,7 @@ public async Task CompactAsyncSkipsPreExcludedAndSystemGroupsAsync() new ChatMessage(ChatRole.User, "Q1"), ]; - MessageIndex index = MessageIndex.Create(messages); + CompactionMessageIndex index = CompactionMessageIndex.Create(messages); // Pre-exclude the last user group index.Groups[index.Groups.Count - 1].IsExcluded = true; diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/TruncationCompactionStrategyTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/TruncationCompactionStrategyTests.cs index 2783fa029c..8b42abc31d 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/TruncationCompactionStrategyTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/TruncationCompactionStrategyTests.cs @@ -17,7 +17,7 @@ public async Task CompactAsyncAlwaysTriggerCompactsToPreserveRecentAsync() { // Arrange — always-trigger means always compact TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreserved: 1); - MessageIndex groups = MessageIndex.Create( + CompactionMessageIndex groups = CompactionMessageIndex.Create( [ new ChatMessage(ChatRole.User, "First"), new ChatMessage(ChatRole.Assistant, "Response 1"), @@ -40,7 +40,7 @@ public async Task CompactAsyncTriggerNotMetReturnsFalseAsync() minimumPreserved: 1, trigger: CompactionTriggers.TokensExceed(1000)); - MessageIndex groups = MessageIndex.Create( + CompactionMessageIndex groups = CompactionMessageIndex.Create( [ new ChatMessage(ChatRole.User, "Hello"), new ChatMessage(ChatRole.Assistant, "Hi!"), @@ -62,7 +62,7 @@ public async Task CompactAsyncTriggerMetExcludesOldestGroupsAsync() minimumPreserved: 1, trigger: CompactionTriggers.GroupsExceed(2)); - MessageIndex groups = MessageIndex.Create( + CompactionMessageIndex groups = CompactionMessageIndex.Create( [ new ChatMessage(ChatRole.User, "First"), new ChatMessage(ChatRole.Assistant, "Response 1"), @@ -88,7 +88,7 @@ public async Task CompactAsyncPreservesSystemMessagesAsync() { // Arrange TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreserved: 1); - MessageIndex groups = MessageIndex.Create( + CompactionMessageIndex groups = CompactionMessageIndex.Create( [ new ChatMessage(ChatRole.System, "You are helpful."), new ChatMessage(ChatRole.User, "First"), @@ -103,7 +103,7 @@ public async Task CompactAsyncPreservesSystemMessagesAsync() Assert.True(result); // System message should be preserved Assert.False(groups.Groups[0].IsExcluded); - Assert.Equal(MessageGroupKind.System, groups.Groups[0].Kind); + Assert.Equal(CompactionGroupKind.System, groups.Groups[0].Kind); // Oldest non-system groups excluded Assert.True(groups.Groups[1].IsExcluded); Assert.True(groups.Groups[2].IsExcluded); @@ -121,7 +121,7 @@ public async Task CompactAsyncPreservesToolCallGroupAtomicityAsync() ChatMessage toolResult = new(ChatRole.Tool, "Sunny"); ChatMessage finalResponse = new(ChatRole.User, "Thanks!"); - MessageIndex groups = MessageIndex.Create([assistantToolCall, toolResult, finalResponse]); + CompactionMessageIndex groups = CompactionMessageIndex.Create([assistantToolCall, toolResult, finalResponse]); // Act bool result = await strategy.CompactAsync(groups); @@ -130,7 +130,7 @@ public async Task CompactAsyncPreservesToolCallGroupAtomicityAsync() 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(CompactionGroupKind.ToolCall, groups.Groups[0].Kind); Assert.Equal(2, groups.Groups[0].Messages.Count); Assert.False(groups.Groups[1].IsExcluded); } @@ -140,7 +140,7 @@ public async Task CompactAsyncSetsExcludeReasonAsync() { // Arrange TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreserved: 1); - MessageIndex groups = MessageIndex.Create( + CompactionMessageIndex groups = CompactionMessageIndex.Create( [ new ChatMessage(ChatRole.User, "Old"), new ChatMessage(ChatRole.User, "New"), @@ -159,7 +159,7 @@ public async Task CompactAsyncSkipsAlreadyExcludedGroupsAsync() { // Arrange TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreserved: 1); - MessageIndex groups = MessageIndex.Create( + CompactionMessageIndex groups = CompactionMessageIndex.Create( [ new ChatMessage(ChatRole.User, "Already excluded"), new ChatMessage(ChatRole.User, "Included 1"), @@ -182,7 +182,7 @@ public async Task CompactAsyncMinimumPreservedKeepsMultipleAsync() { // Arrange — keep 2 most recent TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreserved: 2); - MessageIndex groups = MessageIndex.Create( + CompactionMessageIndex groups = CompactionMessageIndex.Create( [ new ChatMessage(ChatRole.User, "Q1"), new ChatMessage(ChatRole.Assistant, "A1"), @@ -206,7 +206,7 @@ public async Task CompactAsyncNothingToRemoveReturnsFalseAsync() { // Arrange — preserve 5 but only 2 groups TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreserved: 5); - MessageIndex groups = MessageIndex.Create( + CompactionMessageIndex groups = CompactionMessageIndex.Create( [ new ChatMessage(ChatRole.User, "Hello"), new ChatMessage(ChatRole.Assistant, "Hi!"), @@ -224,14 +224,14 @@ public async Task CompactAsyncCustomTargetStopsEarlyAsync() { // Arrange — always trigger, custom target stops after 1 exclusion int targetChecks = 0; - bool TargetAfterOne(MessageIndex _) => ++targetChecks >= 1; + bool TargetAfterOne(CompactionMessageIndex _) => ++targetChecks >= 1; TruncationCompactionStrategy strategy = new( CompactionTriggers.Always, minimumPreserved: 1, target: TargetAfterOne); - MessageIndex groups = MessageIndex.Create( + CompactionMessageIndex groups = CompactionMessageIndex.Create( [ new ChatMessage(ChatRole.User, "Q1"), new ChatMessage(ChatRole.Assistant, "A1"), @@ -258,7 +258,7 @@ public async Task CompactAsyncIncrementalStopsAtTargetAsync() CompactionTriggers.GroupsExceed(2), minimumPreserved: 1); - MessageIndex groups = MessageIndex.Create( + CompactionMessageIndex groups = CompactionMessageIndex.Create( [ new ChatMessage(ChatRole.User, "Q1"), new ChatMessage(ChatRole.Assistant, "A1"), @@ -280,7 +280,7 @@ 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( + CompactionMessageIndex groups = CompactionMessageIndex.Create( [ new ChatMessage(ChatRole.User, "Q1"), new ChatMessage(ChatRole.Assistant, "A1"), @@ -305,7 +305,7 @@ 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( + CompactionMessageIndex groups = CompactionMessageIndex.Create( [ new ChatMessage(ChatRole.System, "System"), new ChatMessage(ChatRole.User, "Q1"), From 70ae2454f1cee36af477e4b959813bc2067afea0 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Tue, 10 Mar 2026 15:14:54 -0700 Subject: [PATCH 68/80] Fix sliding window --- .../SlidingWindowCompactionStrategy.cs | 105 ++++++++---------- .../SummarizationCompactionStrategy.cs | 21 ++-- .../ToolResultCompactionStrategy.cs | 16 +-- .../TruncationCompactionStrategy.cs | 16 +-- .../Compaction/CompactionProviderTests.cs | 8 +- .../SlidingWindowCompactionStrategyTests.cs | 73 +++++++++++- .../SummarizationCompactionStrategyTests.cs | 28 ++--- .../ToolResultCompactionStrategyTests.cs | 16 +-- .../TruncationCompactionStrategyTests.cs | 26 ++--- 9 files changed, 184 insertions(+), 125 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/SlidingWindowCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/SlidingWindowCompactionStrategy.cs index e9c4ea5e72..be74e679bb 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/SlidingWindowCompactionStrategy.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/SlidingWindowCompactionStrategy.cs @@ -21,8 +21,10 @@ namespace Microsoft.Agents.AI.Compaction; /// one at a time until the condition is met. /// /// -/// is a hard floor: even if the -/// has not been reached, compaction will not touch the last non-system groups. +/// is a hard floor: even if the +/// has not been reached, compaction will not touch the last turns +/// (by ). Groups with a +/// of 0 or are always preserved regardless of this setting. /// /// /// This strategy is more predictable than token-based truncation for bounding conversation @@ -33,7 +35,7 @@ namespace Microsoft.Agents.AI.Compaction; public sealed class SlidingWindowCompactionStrategy : CompactionStrategy { /// - /// The default minimum number of most-recent non-system groups to preserve. + /// The default minimum number of most-recent turns to preserve. /// public const int DefaultMinimumPreserved = 1; @@ -44,101 +46,92 @@ public sealed class SlidingWindowCompactionStrategy : CompactionStrategy /// 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. + /// + /// The minimum number of most-recent turns (by ) to preserve. + /// This is a hard floor — compaction will not exclude turns within this range, regardless of the target condition. + /// Groups with of 0 or are always preserved. /// /// /// 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 SlidingWindowCompactionStrategy(CompactionTrigger trigger, int minimumPreserved = DefaultMinimumPreserved, CompactionTrigger? target = null) + public SlidingWindowCompactionStrategy(CompactionTrigger trigger, int minimumPreservedTurns = DefaultMinimumPreserved, CompactionTrigger? target = null) : base(trigger, target) { - this.MinimumPreserved = EnsureNonNegative(minimumPreserved); + this.MinimumPreservedTurns = EnsureNonNegative(minimumPreservedTurns); } /// - /// Gets the minimum number of most-recent non-system groups that are always preserved. + /// Gets the minimum number of most-recent turns (by ) that are always preserved. /// This is a hard floor that compaction cannot exceed, regardless of the target condition. + /// Groups with of 0 or are always preserved + /// independently of this value. /// - public int MinimumPreserved { get; } + public int MinimumPreservedTurns { get; } /// protected override ValueTask CompactCoreAsync(CompactionMessageIndex index, ILogger logger, CancellationToken cancellationToken) { - // Forward pass: count non-system included groups and pre-index them by TurnIndex. - int nonSystemIncludedCount = 0; + // Forward pass: pre-index non-system included groups by TurnIndex. Dictionary> turnGroups = []; List turnOrder = []; for (int i = 0; i < index.Groups.Count; i++) { CompactionMessageGroup group = index.Groups[i]; - if (!group.IsExcluded && group.Kind != CompactionGroupKind.System) + if (!group.IsExcluded && group.Kind != CompactionGroupKind.System && group.TurnIndex is int turnIndex) { - nonSystemIncludedCount++; - - if (group.TurnIndex is int turnIndex) + if (!turnGroups.TryGetValue(turnIndex, out List? indices)) { - if (!turnGroups.TryGetValue(turnIndex, out List? indices)) - { - indices = []; - turnGroups[turnIndex] = indices; - turnOrder.Add(turnIndex); - } - - indices.Add(i); + indices = []; + turnGroups[turnIndex] = indices; + turnOrder.Add(turnIndex); } + + indices.Add(i); } } - // Backward pass: identify the protected tail (last MinimumPreserved non-system included groups). - int protectedCount = Math.Min(this.MinimumPreserved, nonSystemIncludedCount); -#if NETSTANDARD - HashSet protectedIndices = []; -#else - HashSet protectedIndices = new(protectedCount); -#endif - int remaining = protectedCount; - for (int i = index.Groups.Count - 1; i >= 0 && remaining > 0; i--) + // Backward pass: identify protected turns by TurnIndex. + // TurnIndex = 0 is always protected (non-system messages before first user message). + // TurnIndex = null is always protected (system messages, already excluded from turn tracking). + HashSet protectedTurnIndices = []; + if (turnGroups.ContainsKey(0)) { - CompactionMessageGroup group = index.Groups[i]; - if (!group.IsExcluded && group.Kind != CompactionGroupKind.System) - { - protectedIndices.Add(i); - remaining--; - } + protectedTurnIndices.Add(0); + } + + // Protect the last MinimumPreservedTurns distinct turns. + int turnsToProtect = Math.Min(this.MinimumPreservedTurns, turnOrder.Count); + for (int i = turnOrder.Count - turnsToProtect; i < turnOrder.Count; i++) + { + protectedTurnIndices.Add(turnOrder[i]); } - // Exclude turns oldest-first using the pre-built index, checking target after each turn. + // Exclude turns oldest-first, skipping protected turns, checking target after each turn. bool compacted = false; for (int t = 0; t < turnOrder.Count; t++) { - List groupIndices = turnGroups[turnOrder[t]]; - bool anyExcluded = false; + int currentTurnIndex = turnOrder[t]; + if (protectedTurnIndices.Contains(currentTurnIndex)) + { + continue; + } + List groupIndices = turnGroups[currentTurnIndex]; for (int g = 0; g < groupIndices.Count; g++) { int idx = groupIndices[g]; - if (!protectedIndices.Contains(idx)) - { - index.Groups[idx].IsExcluded = true; - index.Groups[idx].ExcludeReason = $"Excluded by {nameof(SlidingWindowCompactionStrategy)}"; - anyExcluded = true; - } + index.Groups[idx].IsExcluded = true; + index.Groups[idx].ExcludeReason = $"Excluded by {nameof(SlidingWindowCompactionStrategy)}"; } - if (anyExcluded) - { - compacted = true; + compacted = true; - if (this.Target(index)) - { - break; - } + if (this.Target(index)) + { + break; } } diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/SummarizationCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/SummarizationCompactionStrategy.cs index b1b525aa78..5ad788f9f3 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/SummarizationCompactionStrategy.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/SummarizationCompactionStrategy.cs @@ -19,14 +19,14 @@ 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. +/// 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. Use @@ -50,6 +50,11 @@ public sealed class SummarizationCompactionStrategy : CompactionStrategy Omit pleasantries and redundant exchanges. Be factual and brief. """; + /// + /// The default minimum number of most-recent non-system groups to preserve. + /// + public const int DefaultMinimumPreserved = 8; + /// /// Initializes a new instance of the class. /// @@ -57,7 +62,7 @@ Omit pleasantries and redundant exchanges. Be factual and brief. /// /// The that controls when compaction proceeds. /// - /// + /// /// 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. @@ -73,13 +78,13 @@ Omit pleasantries and redundant exchanges. Be factual and brief. public SummarizationCompactionStrategy( IChatClient chatClient, CompactionTrigger trigger, - int minimumPreserved = 4, + int minimumPreservedGroups = DefaultMinimumPreserved, string? summarizationPrompt = null, CompactionTrigger? target = null) : base(trigger, target) { this.ChatClient = Throw.IfNull(chatClient); - this.MinimumPreserved = EnsureNonNegative(minimumPreserved); + this.MinimumPreservedGroups = EnsureNonNegative(minimumPreservedGroups); this.SummarizationPrompt = summarizationPrompt ?? DefaultSummarizationPrompt; } @@ -92,7 +97,7 @@ public SummarizationCompactionStrategy( /// 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 MinimumPreserved { get; } + public int MinimumPreservedGroups { get; } /// /// Gets the prompt used when requesting summaries from the chat client. @@ -113,7 +118,7 @@ protected override async ValueTask CompactCoreAsync(CompactionMessageIndex } } - int protectedFromEnd = Math.Min(this.MinimumPreserved, nonSystemIncludedCount); + int protectedFromEnd = Math.Min(this.MinimumPreservedGroups, 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 63b140d451..c9abcf6499 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/ToolResultCompactionStrategy.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/ToolResultCompactionStrategy.cs @@ -23,8 +23,8 @@ 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. +/// 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. Use @@ -37,7 +37,7 @@ public sealed class ToolResultCompactionStrategy : CompactionStrategy /// /// The default minimum number of most-recent non-system groups to preserve. /// - public const int DefaultMinimumPreserved = 2; + public const int DefaultMinimumPreserved = 16; /// /// Initializes a new instance of the class. @@ -45,7 +45,7 @@ public sealed class ToolResultCompactionStrategy : CompactionStrategy /// /// The that controls when compaction proceeds. /// - /// + /// /// 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. @@ -55,17 +55,17 @@ public sealed class ToolResultCompactionStrategy : CompactionStrategy /// 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 minimumPreserved = DefaultMinimumPreserved, CompactionTrigger? target = null) + public ToolResultCompactionStrategy(CompactionTrigger trigger, int minimumPreservedGroups = DefaultMinimumPreserved, CompactionTrigger? target = null) : base(trigger, target) { - this.MinimumPreserved = EnsureNonNegative(minimumPreserved); + this.MinimumPreservedGroups = EnsureNonNegative(minimumPreservedGroups); } /// /// 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 MinimumPreserved { get; } + public int MinimumPreservedGroups { get; } /// protected override ValueTask CompactCoreAsync(CompactionMessageIndex index, ILogger logger, CancellationToken cancellationToken) @@ -81,7 +81,7 @@ protected override ValueTask CompactCoreAsync(CompactionMessageIndex index } } - int protectedStart = EnsureNonNegative(nonSystemIncludedIndices.Count - this.MinimumPreserved); + int protectedStart = EnsureNonNegative(nonSystemIncludedIndices.Count - this.MinimumPreservedGroups); 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 70b4f1e5d3..9f816fece1 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/TruncationCompactionStrategy.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/TruncationCompactionStrategy.cs @@ -10,7 +10,7 @@ namespace Microsoft.Agents.AI.Compaction; /// /// A compaction strategy that removes the oldest non-system message groups, -/// keeping at least most-recent groups intact. +/// keeping at least most-recent groups intact. /// /// /// @@ -19,8 +19,8 @@ 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. +/// 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. @@ -41,7 +41,7 @@ public sealed class TruncationCompactionStrategy : CompactionStrategy /// /// The that controls when compaction proceeds. /// - /// + /// /// 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. @@ -50,17 +50,17 @@ public sealed class TruncationCompactionStrategy : CompactionStrategy /// 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 minimumPreserved = DefaultMinimumPreserved, CompactionTrigger? target = null) + public TruncationCompactionStrategy(CompactionTrigger trigger, int minimumPreservedGroups = DefaultMinimumPreserved, CompactionTrigger? target = null) : base(trigger, target) { - this.MinimumPreserved = EnsureNonNegative(minimumPreserved); + this.MinimumPreservedGroups = EnsureNonNegative(minimumPreservedGroups); } /// /// 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 MinimumPreserved { get; } + public int MinimumPreservedGroups { get; } /// protected override ValueTask CompactCoreAsync(CompactionMessageIndex index, ILogger logger, CancellationToken cancellationToken) @@ -76,7 +76,7 @@ protected override ValueTask CompactCoreAsync(CompactionMessageIndex index } } - int maxRemovable = removableCount - this.MinimumPreserved; + int maxRemovable = removableCount - this.MinimumPreservedGroups; if (maxRemovable <= 0) { return new ValueTask(false); diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionProviderTests.cs index 85a0445fa5..317f7d86ed 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionProviderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionProviderTests.cs @@ -105,7 +105,7 @@ public async Task InvokingAsyncNullMessagesPassesThroughAsync() public async Task InvokingAsyncAppliesCompactionWhenTriggeredAsync() { // Arrange — strategy that always triggers and keeps only 1 group - TruncationCompactionStrategy strategy = new(_ => true, minimumPreserved: 1); + TruncationCompactionStrategy strategy = new(_ => true, minimumPreservedGroups: 1); CompactionProvider provider = new(strategy); Mock mockAgent = new() { CallBase = true }; @@ -194,7 +194,7 @@ public async Task InvokingAsyncPreservesInstructionsAndToolsAsync() public async Task InvokingAsyncWithExistingIndexUpdatesAsync() { // Arrange — call twice to exercise the "existing index" path - TruncationCompactionStrategy strategy = new(_ => true, minimumPreserved: 1); + TruncationCompactionStrategy strategy = new(_ => true, minimumPreservedGroups: 1); CompactionProvider provider = new(strategy); Mock mockAgent = new() { CallBase = true }; @@ -299,7 +299,7 @@ public async Task CompactAsyncReturnsAllMessagesWhenTriggerDoesNotFireAsync() public async Task CompactAsyncReducesMessagesWhenTriggeredAsync() { // Arrange — strategy that always triggers and keeps only 1 group - TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreserved: 1); + TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreservedGroups: 1); List messages = [ new ChatMessage(ChatRole.User, "Q1"), @@ -319,7 +319,7 @@ public async Task CompactAsyncReducesMessagesWhenTriggeredAsync() public async Task CompactAsyncHandlesEmptyMessageListAsync() { // Arrange - TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreserved: 1); + TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreservedGroups: 1); List messages = []; // Act diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/SlidingWindowCompactionStrategyTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/SlidingWindowCompactionStrategyTests.cs index ec7753b131..46a5cc3be6 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/SlidingWindowCompactionStrategyTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/SlidingWindowCompactionStrategyTests.cs @@ -167,7 +167,7 @@ public async Task CompactAsyncCustomTargetStopsExcludingEarlyAsync() SlidingWindowCompactionStrategy strategy = new( CompactionTriggers.TurnsExceed(1), - minimumPreserved: 0, + minimumPreservedTurns: 0, target: TargetAfterOne); CompactionMessageIndex index = CompactionMessageIndex.Create( @@ -198,7 +198,7 @@ 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, + minimumPreservedTurns: 2, target: _ => false); CompactionMessageIndex index = CompactionMessageIndex.Create( @@ -214,10 +214,15 @@ public async Task CompactAsyncMinimumPreservedStopsCompactionAsync() // Act bool result = await strategy.CompactAsync(index); - // Assert — target never says stop, but MinimumPreserved=2 prevents removing the last 2 groups + // Assert — target never says stop, but MinimumPreserved=2 protects the last 2 turns Assert.True(result); - Assert.Equal(2, index.IncludedGroupCount); - // Last 2 non-system groups must be preserved + Assert.Equal(4, index.IncludedGroupCount); + // Turn 1 excluded + Assert.True(index.Groups[0].IsExcluded); // Q1 + Assert.True(index.Groups[1].IsExcluded); // A1 + // Last 2 turns must be preserved + Assert.False(index.Groups[2].IsExcluded); // Q2 + Assert.False(index.Groups[3].IsExcluded); // A2 Assert.False(index.Groups[4].IsExcluded); // Q3 Assert.False(index.Groups[5].IsExcluded); // A3 } @@ -228,7 +233,7 @@ public async Task CompactAsyncSkipsExcludedAndSystemGroupsInEnumerationAsync() // Arrange — includes system and pre-excluded groups that must be skipped SlidingWindowCompactionStrategy strategy = new( CompactionTriggers.TurnsExceed(1), - minimumPreserved: 0); + minimumPreservedTurns: 0); CompactionMessageIndex index = CompactionMessageIndex.Create( [ @@ -247,4 +252,60 @@ public async Task CompactAsyncSkipsExcludedAndSystemGroupsInEnumerationAsync() Assert.True(result); Assert.False(index.Groups[0].IsExcluded); // System preserved } + + [Fact] + public async Task CompactAsyncPreservesTurnIndexZeroAsync() + { + // Arrange — assistant message before first user turn gets TurnIndex = 0 + SlidingWindowCompactionStrategy strategy = new( + CompactionTriggers.TurnsExceed(1), + minimumPreservedTurns: 0, + target: _ => false); + + CompactionMessageIndex index = CompactionMessageIndex.Create( + [ + new ChatMessage(ChatRole.Assistant, "Welcome!"), // TurnIndex = 0 + new ChatMessage(ChatRole.User, "Q1"), // TurnIndex = 1 + new ChatMessage(ChatRole.Assistant, "A1"), // TurnIndex = 1 + new ChatMessage(ChatRole.User, "Q2"), // TurnIndex = 2 + new ChatMessage(ChatRole.Assistant, "A2"), // TurnIndex = 2 + ]); + + // Act + bool result = await strategy.CompactAsync(index); + + // Assert — TurnIndex = 0 is always preserved even with minimumPreservedTurns = 0 + Assert.True(result); + Assert.False(index.Groups[0].IsExcluded); // Welcome (TurnIndex 0) preserved + Assert.True(index.Groups[1].IsExcluded); // Q1 (TurnIndex 1) excluded + Assert.True(index.Groups[2].IsExcluded); // A1 (TurnIndex 1) excluded + Assert.True(index.Groups[3].IsExcluded); // Q2 (TurnIndex 2) excluded + Assert.True(index.Groups[4].IsExcluded); // A2 (TurnIndex 2) excluded + } + + [Fact] + public async Task CompactAsyncPreservesNullTurnIndexAsync() + { + // Arrange — system messages (TurnIndex = null) should never be removed + SlidingWindowCompactionStrategy strategy = new( + CompactionTriggers.TurnsExceed(0), + minimumPreservedTurns: 0, + target: _ => false); + + CompactionMessageIndex index = CompactionMessageIndex.Create( + [ + new ChatMessage(ChatRole.System, "You are helpful."), + new ChatMessage(ChatRole.User, "Q1"), + new ChatMessage(ChatRole.Assistant, "A1"), + ]); + + // Act + bool result = await strategy.CompactAsync(index); + + // Assert — system message (TurnIndex null) always preserved + Assert.True(result); + Assert.False(index.Groups[0].IsExcluded); // System (TurnIndex null) preserved + Assert.True(index.Groups[1].IsExcluded); // Q1 excluded + Assert.True(index.Groups[2].IsExcluded); // A1 excluded + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/SummarizationCompactionStrategyTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/SummarizationCompactionStrategyTests.cs index 266a84df8a..9b05aee3ff 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/SummarizationCompactionStrategyTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/SummarizationCompactionStrategyTests.cs @@ -36,7 +36,7 @@ public async Task CompactAsyncTriggerNotMetReturnsFalseAsync() SummarizationCompactionStrategy strategy = new( CreateMockChatClient(), CompactionTriggers.TokensExceed(100000), - minimumPreserved: 1); + minimumPreservedGroups: 1); CompactionMessageIndex index = CompactionMessageIndex.Create( [ @@ -59,7 +59,7 @@ public async Task CompactAsyncSummarizesOldGroupsAsync() SummarizationCompactionStrategy strategy = new( CreateMockChatClient("Key facts from earlier."), CompactionTriggers.Always, - minimumPreserved: 1); + minimumPreservedGroups: 1); CompactionMessageIndex index = CompactionMessageIndex.Create( [ @@ -90,7 +90,7 @@ public async Task CompactAsyncPreservesSystemMessagesAsync() SummarizationCompactionStrategy strategy = new( CreateMockChatClient(), CompactionTriggers.Always, - minimumPreserved: 1); + minimumPreservedGroups: 1); CompactionMessageIndex index = CompactionMessageIndex.Create( [ @@ -117,7 +117,7 @@ public async Task CompactAsyncInsertsSummaryGroupAtCorrectPositionAsync() SummarizationCompactionStrategy strategy = new( CreateMockChatClient("Summary text."), CompactionTriggers.Always, - minimumPreserved: 1); + minimumPreservedGroups: 1); CompactionMessageIndex index = CompactionMessageIndex.Create( [ @@ -144,7 +144,7 @@ public async Task CompactAsyncHandlesEmptyLlmResponseAsync() SummarizationCompactionStrategy strategy = new( CreateMockChatClient(" "), CompactionTriggers.Always, - minimumPreserved: 1); + minimumPreservedGroups: 1); CompactionMessageIndex index = CompactionMessageIndex.Create( [ @@ -167,7 +167,7 @@ public async Task CompactAsyncNothingToSummarizeReturnsFalseAsync() SummarizationCompactionStrategy strategy = new( CreateMockChatClient(), CompactionTriggers.Always, - minimumPreserved: 5); + minimumPreservedGroups: 5); CompactionMessageIndex index = CompactionMessageIndex.Create( [ @@ -200,7 +200,7 @@ public async Task CompactAsyncUsesCustomPromptAsync() SummarizationCompactionStrategy strategy = new( mockClient.Object, CompactionTriggers.Always, - minimumPreserved: 1, + minimumPreservedGroups: 1, summarizationPrompt: CustomPrompt); CompactionMessageIndex index = CompactionMessageIndex.Create( @@ -228,7 +228,7 @@ public async Task CompactAsyncSetsExcludeReasonAsync() SummarizationCompactionStrategy strategy = new( CreateMockChatClient(), CompactionTriggers.Always, - minimumPreserved: 1); + minimumPreservedGroups: 1); CompactionMessageIndex index = CompactionMessageIndex.Create( [ @@ -255,7 +255,7 @@ public async Task CompactAsyncTargetStopsMarkingEarlyAsync() SummarizationCompactionStrategy strategy = new( CreateMockChatClient("Partial summary."), CompactionTriggers.Always, - minimumPreserved: 1, + minimumPreservedGroups: 1, target: TargetAfterOne); CompactionMessageIndex index = CompactionMessageIndex.Create( @@ -281,7 +281,7 @@ public async Task CompactAsyncPreservesMultipleRecentGroupsAsync() SummarizationCompactionStrategy strategy = new( CreateMockChatClient("Summary."), CompactionTriggers.Always, - minimumPreserved: 2); + minimumPreservedGroups: 2); CompactionMessageIndex index = CompactionMessageIndex.Create( [ @@ -310,7 +310,7 @@ public async Task CompactAsyncWithSystemBetweenSummarizableGroupsAsync() SummarizationCompactionStrategy strategy = new( mockClient, CompactionTriggers.Always, - minimumPreserved: 1); + minimumPreservedGroups: 1); CompactionMessageIndex index = CompactionMessageIndex.Create( [ @@ -338,7 +338,7 @@ public async Task CompactAsyncMaxSummarizableBoundsLoopExitAsync() SummarizationCompactionStrategy strategy = new( mockClient, CompactionTriggers.Always, - minimumPreserved: 3, + minimumPreservedGroups: 3, target: _ => false); CompactionMessageIndex index = CompactionMessageIndex.Create( @@ -367,7 +367,7 @@ public async Task CompactAsyncWithPreExcludedGroupAsync() SummarizationCompactionStrategy strategy = new( mockClient, CompactionTriggers.Always, - minimumPreserved: 1); + minimumPreservedGroups: 1); CompactionMessageIndex index = CompactionMessageIndex.Create( [ @@ -394,7 +394,7 @@ public async Task CompactAsyncWithEmptyTextMessageInGroupAsync() SummarizationCompactionStrategy strategy = new( mockClient, CompactionTriggers.Always, - minimumPreserved: 1); + minimumPreservedGroups: 1); List messages = [ diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/ToolResultCompactionStrategyTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/ToolResultCompactionStrategyTests.cs index 8ee39615c1..d06a791450 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/ToolResultCompactionStrategyTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/ToolResultCompactionStrategyTests.cs @@ -41,7 +41,7 @@ public async Task CompactAsyncCollapsesOldToolGroupsAsync() // Arrange — always trigger ToolResultCompactionStrategy strategy = new( trigger: _ => true, - minimumPreserved: 1); + minimumPreservedGroups: 1); CompactionMessageIndex groups = CompactionMessageIndex.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, - minimumPreserved: 3); + minimumPreservedGroups: 3); CompactionMessageIndex groups = CompactionMessageIndex.Create( [ @@ -94,7 +94,7 @@ public async Task CompactAsyncPreservesSystemMessagesAsync() // Arrange ToolResultCompactionStrategy strategy = new( trigger: _ => true, - minimumPreserved: 1); + minimumPreservedGroups: 1); CompactionMessageIndex groups = CompactionMessageIndex.Create( [ @@ -119,7 +119,7 @@ public async Task CompactAsyncExtractsMultipleToolNamesAsync() // Arrange — assistant calls two tools ToolResultCompactionStrategy strategy = new( trigger: _ => true, - minimumPreserved: 1); + minimumPreservedGroups: 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, - minimumPreserved: 0); + minimumPreservedGroups: 0); CompactionMessageIndex groups = CompactionMessageIndex.Create( [ @@ -175,7 +175,7 @@ public async Task CompactAsyncCompoundTriggerRequiresTokensAndToolCallsAsync() CompactionTriggers.All( CompactionTriggers.TokensExceed(0), CompactionTriggers.HasToolCalls()), - minimumPreserved: 1); + minimumPreservedGroups: 1); CompactionMessageIndex groups = CompactionMessageIndex.Create( [ @@ -201,7 +201,7 @@ public async Task CompactAsyncTargetStopsCollapsingEarlyAsync() ToolResultCompactionStrategy strategy = new( trigger: _ => true, - minimumPreserved: 1, + minimumPreservedGroups: 1, target: TargetAfterOne); CompactionMessageIndex index = CompactionMessageIndex.Create( @@ -238,7 +238,7 @@ public async Task CompactAsyncTargetStopsCollapsingEarlyAsync() public async Task CompactAsyncSkipsPreExcludedAndSystemGroupsAsync() { // Arrange — pre-excluded and system groups in the enumeration - ToolResultCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreserved: 0); + ToolResultCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreservedGroups: 0); List messages = [ diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/TruncationCompactionStrategyTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/TruncationCompactionStrategyTests.cs index 8b42abc31d..e0e48d07e4 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/TruncationCompactionStrategyTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/TruncationCompactionStrategyTests.cs @@ -16,7 +16,7 @@ public class TruncationCompactionStrategyTests public async Task CompactAsyncAlwaysTriggerCompactsToPreserveRecentAsync() { // Arrange — always-trigger means always compact - TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreserved: 1); + TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreservedGroups: 1); CompactionMessageIndex groups = CompactionMessageIndex.Create( [ new ChatMessage(ChatRole.User, "First"), @@ -37,7 +37,7 @@ public async Task CompactAsyncTriggerNotMetReturnsFalseAsync() { // Arrange — trigger requires > 1000 tokens, conversation is tiny TruncationCompactionStrategy strategy = new( - minimumPreserved: 1, + minimumPreservedGroups: 1, trigger: CompactionTriggers.TokensExceed(1000)); CompactionMessageIndex groups = CompactionMessageIndex.Create( @@ -59,7 +59,7 @@ public async Task CompactAsyncTriggerMetExcludesOldestGroupsAsync() { // Arrange — trigger on groups > 2 TruncationCompactionStrategy strategy = new( - minimumPreserved: 1, + minimumPreservedGroups: 1, trigger: CompactionTriggers.GroupsExceed(2)); CompactionMessageIndex groups = CompactionMessageIndex.Create( @@ -87,7 +87,7 @@ public async Task CompactAsyncTriggerMetExcludesOldestGroupsAsync() public async Task CompactAsyncPreservesSystemMessagesAsync() { // Arrange - TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreserved: 1); + TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreservedGroups: 1); CompactionMessageIndex groups = CompactionMessageIndex.Create( [ new ChatMessage(ChatRole.System, "You are helpful."), @@ -115,7 +115,7 @@ public async Task CompactAsyncPreservesSystemMessagesAsync() public async Task CompactAsyncPreservesToolCallGroupAtomicityAsync() { // Arrange - TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreserved: 1); + TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreservedGroups: 1); ChatMessage assistantToolCall = new(ChatRole.Assistant, [new FunctionCallContent("call1", "get_weather")]); ChatMessage toolResult = new(ChatRole.Tool, "Sunny"); @@ -139,7 +139,7 @@ public async Task CompactAsyncPreservesToolCallGroupAtomicityAsync() public async Task CompactAsyncSetsExcludeReasonAsync() { // Arrange - TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreserved: 1); + TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreservedGroups: 1); CompactionMessageIndex groups = CompactionMessageIndex.Create( [ new ChatMessage(ChatRole.User, "Old"), @@ -158,7 +158,7 @@ public async Task CompactAsyncSetsExcludeReasonAsync() public async Task CompactAsyncSkipsAlreadyExcludedGroupsAsync() { // Arrange - TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreserved: 1); + TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreservedGroups: 1); CompactionMessageIndex groups = CompactionMessageIndex.Create( [ new ChatMessage(ChatRole.User, "Already excluded"), @@ -181,7 +181,7 @@ public async Task CompactAsyncSkipsAlreadyExcludedGroupsAsync() public async Task CompactAsyncMinimumPreservedKeepsMultipleAsync() { // Arrange — keep 2 most recent - TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreserved: 2); + TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreservedGroups: 2); CompactionMessageIndex groups = CompactionMessageIndex.Create( [ new ChatMessage(ChatRole.User, "Q1"), @@ -205,7 +205,7 @@ public async Task CompactAsyncMinimumPreservedKeepsMultipleAsync() public async Task CompactAsyncNothingToRemoveReturnsFalseAsync() { // Arrange — preserve 5 but only 2 groups - TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreserved: 5); + TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreservedGroups: 5); CompactionMessageIndex groups = CompactionMessageIndex.Create( [ new ChatMessage(ChatRole.User, "Hello"), @@ -228,7 +228,7 @@ public async Task CompactAsyncCustomTargetStopsEarlyAsync() TruncationCompactionStrategy strategy = new( CompactionTriggers.Always, - minimumPreserved: 1, + minimumPreservedGroups: 1, target: TargetAfterOne); CompactionMessageIndex groups = CompactionMessageIndex.Create( @@ -256,7 +256,7 @@ public async Task CompactAsyncIncrementalStopsAtTargetAsync() // Arrange — trigger on groups > 2, target is default (inverse of trigger: groups <= 2) TruncationCompactionStrategy strategy = new( CompactionTriggers.GroupsExceed(2), - minimumPreserved: 1); + minimumPreservedGroups: 1); CompactionMessageIndex groups = CompactionMessageIndex.Create( [ @@ -279,7 +279,7 @@ public async Task CompactAsyncIncrementalStopsAtTargetAsync() 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); + TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreservedGroups: 2, target: CompactionTriggers.Never); CompactionMessageIndex groups = CompactionMessageIndex.Create( [ new ChatMessage(ChatRole.User, "Q1"), @@ -304,7 +304,7 @@ public async Task CompactAsyncLoopExitsWhenMaxRemovableReachedAsync() public async Task CompactAsyncSkipsPreExcludedAndSystemGroupsAsync() { // Arrange — has excluded + system groups that the loop must skip - TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreserved: 1); + TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreservedGroups: 1); CompactionMessageIndex groups = CompactionMessageIndex.Create( [ new ChatMessage(ChatRole.System, "System"), From d1cadea0966fd28182e3966b2b51ac702b2fcf8f Mon Sep 17 00:00:00 2001 From: Chris <66376200+crickman@users.noreply.github.com> Date: Tue, 10 Mar 2026 15:24:39 -0700 Subject: [PATCH 69/80] Update dotnet/src/Microsoft.Agents.AI/Compaction/SummarizationCompactionStrategy.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../Compaction/SummarizationCompactionStrategy.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/SummarizationCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/SummarizationCompactionStrategy.cs index 5ad788f9f3..3b923e1874 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/SummarizationCompactionStrategy.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/SummarizationCompactionStrategy.cs @@ -65,7 +65,7 @@ Omit pleasantries and redundant exchanges. Be factual and brief. /// /// 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. + /// regardless of the target condition. Defaults to 8, preserving the current and recent exchanges. /// /// /// An optional custom system prompt for the summarization LLM call. When , From efe5f677618689148b2fb2f15d4a7685997e1abd Mon Sep 17 00:00:00 2001 From: Chris <66376200+crickman@users.noreply.github.com> Date: Tue, 10 Mar 2026 15:25:18 -0700 Subject: [PATCH 70/80] Update dotnet/src/Microsoft.Agents.AI.Abstractions/InMemoryChatHistoryProvider.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../InMemoryChatHistoryProvider.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/InMemoryChatHistoryProvider.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/InMemoryChatHistoryProvider.cs index d3743357ac..8db6666c37 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/InMemoryChatHistoryProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/InMemoryChatHistoryProvider.cs @@ -118,7 +118,6 @@ protected override async ValueTask StoreChatHistoryAsync(InvokedContext context, private static async Task ReduceMessagesAsync(IChatReducer reducer, State state, CancellationToken cancellationToken = default) { state.Messages = [.. await reducer.ReduceAsync(state.Messages, cancellationToken).ConfigureAwait(false)]; - return; } /// From 3821ae7ee63c6b23049c3538aaafa589cf019439 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Tue, 10 Mar 2026 15:32:18 -0700 Subject: [PATCH 71/80] Python alignment --- .../Compaction/CompactionLogMessages.cs | 11 + .../Compaction/MessageIndex.cs | 56 ++++- .../SummarizationCompactionStrategy.cs | 35 ++- .../Compaction/MessageIndexTests.cs | 232 ++++++++++++++++++ .../SummarizationCompactionStrategyTests.cs | 199 +++++++++++++++ 5 files changed, 520 insertions(+), 13 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionLogMessages.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionLogMessages.cs index f6461f7a58..6211b988c7 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionLogMessages.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionLogMessages.cs @@ -98,4 +98,15 @@ public static partial void LogSummarizationCompleted( this ILogger logger, int summaryLength, int insertIndex); + + /// + /// Logs when a summarization LLM call fails and groups are restored. + /// + [LoggerMessage( + Level = LogLevel.Warning, + Message = "Summarization failed for {GroupCount} groups; restoring excluded groups and continuing without compaction. Error: {ErrorMessage}")] + public static partial void LogSummarizationFailed( + this ILogger logger, + int groupCount, + string errorMessage); } diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/MessageIndex.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/MessageIndex.cs index 333f7c5d49..6bdf1bc9e5 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/MessageIndex.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/MessageIndex.cs @@ -197,8 +197,10 @@ private void AppendFromMessages(IList messages, int startIndex) List groupMessages = [message]; index++; - // Collect all subsequent tool result messages - while (index < messages.Count && messages[index].Role == ChatRole.Tool) + // Collect all subsequent tool result messages and reasoning-only assistant messages + while (index < messages.Count && + (messages[index].Role == ChatRole.Tool || + (messages[index].Role == ChatRole.Assistant && HasOnlyReasoning(messages[index])))) { groupMessages.Add(messages[index]); index++; @@ -211,6 +213,47 @@ private void AppendFromMessages(IList messages, int startIndex) this.Groups.Add(CreateGroup(MessageGroupKind.Summary, [message], this.Tokenizer, this._currentTurn)); index++; } + else if (message.Role == ChatRole.Assistant && HasOnlyReasoning(message)) + { + // Reasoning-only assistant messages that precede a tool-call assistant message + // are part of the same atomic tool-call group. Look ahead past consecutive + // reasoning messages to find a possible tool-call message. + int lookahead = index + 1; + while (lookahead < messages.Count && + messages[lookahead].Role == ChatRole.Assistant && + HasOnlyReasoning(messages[lookahead])) + { + lookahead++; + } + + if (lookahead < messages.Count && messages[lookahead].Role == ChatRole.Assistant && HasToolCalls(messages[lookahead])) + { + // Group all reasoning messages + the tool-call message together + List groupMessages = []; + for (int j = index; j <= lookahead; j++) + { + groupMessages.Add(messages[j]); + } + + index = lookahead + 1; + + // Collect all subsequent tool result messages and reasoning-only assistant messages + while (index < messages.Count && + (messages[index].Role == ChatRole.Tool || + (messages[index].Role == ChatRole.Assistant && HasOnlyReasoning(messages[index])))) + { + groupMessages.Add(messages[index]); + index++; + } + + this.Groups.Add(CreateGroup(MessageGroupKind.ToolCall, groupMessages, this.Tokenizer, this._currentTurn)); + } + else + { + this.Groups.Add(CreateGroup(MessageGroupKind.AssistantText, [message], this.Tokenizer, this._currentTurn)); + index++; + } + } else { this.Groups.Add(CreateGroup(MessageGroupKind.AssistantText, [message], this.Tokenizer, this._currentTurn)); @@ -476,9 +519,10 @@ private static bool HasToolCalls(ChatMessage message) return false; } - private static bool IsSummaryMessage(ChatMessage message) - { - return message.AdditionalProperties?.TryGetValue(MessageGroup.SummaryPropertyKey, out object? value) is true + private static bool HasOnlyReasoning(ChatMessage message) => + message.Contents.All(content => content is TextReasoningContent); + + private static bool IsSummaryMessage(ChatMessage message) => + message.AdditionalProperties?.TryGetValue(MessageGroup.SummaryPropertyKey, out object? value) is true && value is true; - } } diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/SummarizationCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/SummarizationCompactionStrategy.cs index 7de30fab41..487f873aaf 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/SummarizationCompactionStrategy.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/SummarizationCompactionStrategy.cs @@ -121,12 +121,14 @@ protected override async ValueTask CompactCoreAsync(MessageIndex index, IL return false; } - // Mark oldest non-system groups for summarization one at a time until the target is met + // Mark oldest non-system groups for summarization one at a time until the target is met. + // Track which groups were excluded so we can restore them if the LLM call fails. List summarizationMessages = [new ChatMessage(ChatRole.System, this.SummarizationPrompt)]; - int summarized = 0; + List excludedGroups = []; + List excludedGroupIndices = []; int insertIndex = -1; - for (int i = 0; i < index.Groups.Count && summarized < maxSummarizable; i++) + for (int i = 0; i < index.Groups.Count && excludedGroups.Count < maxSummarizable; i++) { MessageGroup group = index.Groups[i]; if (group.IsExcluded || group.Kind == MessageGroupKind.System) @@ -144,7 +146,8 @@ protected override async ValueTask CompactCoreAsync(MessageIndex index, IL group.IsExcluded = true; group.ExcludeReason = $"Summarized by {nameof(SummarizationCompactionStrategy)}"; - summarized++; + excludedGroups.Add(group); + excludedGroupIndices.Add(i); // Stop marking when target condition is met if (this.Target(index)) @@ -154,14 +157,32 @@ protected override async ValueTask CompactCoreAsync(MessageIndex index, IL } // Generate summary using the chat client (single LLM call for all marked groups) + int summarized = excludedGroups.Count; logger.LogSummarizationStarting(summarized, summarizationMessages.Count - 1, this.ChatClient.GetType().Name); using Activity? summarizeActivity = CompactionTelemetry.ActivitySource.StartActivity(CompactionTelemetry.ActivityNames.Summarize); summarizeActivity?.SetTag(CompactionTelemetry.Tags.GroupsSummarized, summarized); - ChatResponse response = await this.ChatClient.GetResponseAsync( - summarizationMessages, - cancellationToken: cancellationToken).ConfigureAwait(false); + ChatResponse response; + try + { + response = await this.ChatClient.GetResponseAsync( + summarizationMessages, + cancellationToken: cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + // Restore excluded groups so the conversation is not left in an inconsistent state + for (int i = 0; i < excludedGroups.Count; i++) + { + excludedGroups[i].IsExcluded = false; + excludedGroups[i].ExcludeReason = null; + } + + logger.LogSummarizationFailed(summarized, ex.Message); + + return false; + } string summaryText = string.IsNullOrWhiteSpace(response.Text) ? "[Summary unavailable]" : response.Text; diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/MessageIndexTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/MessageIndexTests.cs index 6c5c0aec9d..97276c4369 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/MessageIndexTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/MessageIndexTests.cs @@ -1242,4 +1242,236 @@ public override OperationStatus Decode(IEnumerable ids, Span destinat return OperationStatus.Done; } } + + [Fact] + public void CreateReasoningBeforeToolCallGroupsAtomic() + { + // Arrange — reasoning-only assistant message immediately before a tool-call assistant message + ChatMessage reasoning = new(ChatRole.Assistant, [new TextReasoningContent("I should look up the weather")]); + ChatMessage toolCall = new(ChatRole.Assistant, [new FunctionCallContent("c1", "get_weather")]); + ChatMessage toolResult = new(ChatRole.Tool, [new FunctionResultContent("c1", "Sunny")]); + + List messages = [reasoning, toolCall, toolResult]; + + // Act + MessageIndex index = MessageIndex.Create(messages); + + // Assert — all three messages in a single ToolCall group + Assert.Single(index.Groups); + Assert.Equal(MessageGroupKind.ToolCall, index.Groups[0].Kind); + Assert.Equal(3, index.Groups[0].MessageCount); + Assert.Same(reasoning, index.Groups[0].Messages[0]); + Assert.Same(toolCall, index.Groups[0].Messages[1]); + Assert.Same(toolResult, index.Groups[0].Messages[2]); + } + + [Fact] + public void CreateMultipleReasoningBeforeToolCallGroupsAtomic() + { + // Arrange — multiple consecutive reasoning messages before a tool-call + ChatMessage reasoning1 = new(ChatRole.Assistant, [new TextReasoningContent("First thought")]); + ChatMessage reasoning2 = new(ChatRole.Assistant, [new TextReasoningContent("Second thought")]); + ChatMessage toolCall = new(ChatRole.Assistant, [new FunctionCallContent("c1", "search")]); + ChatMessage toolResult = new(ChatRole.Tool, [new FunctionResultContent("c1", "results")]); + + List messages = [reasoning1, reasoning2, toolCall, toolResult]; + + // Act + MessageIndex index = MessageIndex.Create(messages); + + // Assert — all four messages in a single ToolCall group + Assert.Single(index.Groups); + Assert.Equal(MessageGroupKind.ToolCall, index.Groups[0].Kind); + Assert.Equal(4, index.Groups[0].MessageCount); + } + + [Fact] + public void CreateReasoningNotFollowedByToolCallIsAssistantText() + { + // Arrange — reasoning-only message followed by a user message (no tool call) + ChatMessage reasoning = new(ChatRole.Assistant, [new TextReasoningContent("Thinking...")]); + ChatMessage user = new(ChatRole.User, "Hello"); + + List messages = [reasoning, user]; + + // Act + MessageIndex index = MessageIndex.Create(messages); + + // Assert — reasoning becomes AssistantText, user stays User + Assert.Equal(2, index.Groups.Count); + Assert.Equal(MessageGroupKind.AssistantText, index.Groups[0].Kind); + Assert.Equal(MessageGroupKind.User, index.Groups[1].Kind); + } + + [Fact] + public void CreateReasoningAtEndOfConversationIsAssistantText() + { + // Arrange — reasoning-only message at the end with nothing following it + ChatMessage user = new(ChatRole.User, "Hello"); + ChatMessage reasoning = new(ChatRole.Assistant, [new TextReasoningContent("Thinking...")]); + + List messages = [user, reasoning]; + + // Act + MessageIndex index = MessageIndex.Create(messages); + + // Assert + Assert.Equal(2, index.Groups.Count); + Assert.Equal(MessageGroupKind.User, index.Groups[0].Kind); + Assert.Equal(MessageGroupKind.AssistantText, index.Groups[1].Kind); + } + + [Fact] + public void CreateToolCallFollowedByReasoningInTail() + { + // Arrange — tool-call assistant followed by tool result and then reasoning-only messages + ChatMessage toolCall = new(ChatRole.Assistant, [new FunctionCallContent("c1", "fn")]); + ChatMessage toolResult = new(ChatRole.Tool, [new FunctionResultContent("c1", "data")]); + ChatMessage reasoning = new(ChatRole.Assistant, [new TextReasoningContent("Analyzing result...")]); + + List messages = [toolCall, toolResult, reasoning]; + + // Act + MessageIndex index = MessageIndex.Create(messages); + + // Assert — reasoning after tool result should be included in the same ToolCall group + Assert.Single(index.Groups); + Assert.Equal(MessageGroupKind.ToolCall, index.Groups[0].Kind); + Assert.Equal(3, index.Groups[0].MessageCount); + } + + [Fact] + public void CreateReasoningBetweenToolCallsGroupsCorrectly() + { + // Arrange — reasoning before first tool-call, then another reasoning+tool-call pair + ChatMessage reasoning1 = new(ChatRole.Assistant, [new TextReasoningContent("Plan: call get_weather")]); + ChatMessage toolCall1 = new(ChatRole.Assistant, [new FunctionCallContent("c1", "get_weather")]); + ChatMessage toolResult1 = new(ChatRole.Tool, [new FunctionResultContent("c1", "Sunny")]); + ChatMessage user = new(ChatRole.User, "What else?"); + ChatMessage reasoning2 = new(ChatRole.Assistant, [new TextReasoningContent("Plan: call get_time")]); + ChatMessage toolCall2 = new(ChatRole.Assistant, [new FunctionCallContent("c2", "get_time")]); + ChatMessage toolResult2 = new(ChatRole.Tool, [new FunctionResultContent("c2", "3 PM")]); + + List messages = [reasoning1, toolCall1, toolResult1, user, reasoning2, toolCall2, toolResult2]; + + // Act + MessageIndex index = MessageIndex.Create(messages); + + // Assert — two ToolCall groups with reasoning included, plus one User group + Assert.Equal(3, index.Groups.Count); + Assert.Equal(MessageGroupKind.ToolCall, index.Groups[0].Kind); + Assert.Equal(3, index.Groups[0].MessageCount); // reasoning1 + toolCall1 + toolResult1 + Assert.Equal(MessageGroupKind.User, index.Groups[1].Kind); + Assert.Equal(MessageGroupKind.ToolCall, index.Groups[2].Kind); + Assert.Equal(3, index.Groups[2].MessageCount); // reasoning2 + toolCall2 + toolResult2 + } + + [Fact] + public void CreateReasoningFollowedByNonReasoningAssistantNotGrouped() + { + // Arrange — reasoning-only followed by plain assistant text (not tool call) + ChatMessage reasoning = new(ChatRole.Assistant, [new TextReasoningContent("Thinking...")]); + ChatMessage plainAssistant = new(ChatRole.Assistant, "Here's my answer."); + + List messages = [reasoning, plainAssistant]; + + // Act + MessageIndex index = MessageIndex.Create(messages); + + // Assert — each becomes its own AssistantText group + Assert.Equal(2, index.Groups.Count); + Assert.Equal(MessageGroupKind.AssistantText, index.Groups[0].Kind); + Assert.Equal(MessageGroupKind.AssistantText, index.Groups[1].Kind); + } + + [Fact] + public void CreateMixedReasoningAndToolCallTurnIndex() + { + // Arrange — verify turn index is correctly assigned when reasoning precedes tool call + ChatMessage system = new(ChatRole.System, "You are helpful."); + ChatMessage user = new(ChatRole.User, "Help me"); + ChatMessage reasoning = new(ChatRole.Assistant, [new TextReasoningContent("Let me think")]); + ChatMessage toolCall = new(ChatRole.Assistant, [new FunctionCallContent("c1", "helper")]); + ChatMessage toolResult = new(ChatRole.Tool, [new FunctionResultContent("c1", "done")]); + + List messages = [system, user, reasoning, toolCall, toolResult]; + + // Act + MessageIndex index = MessageIndex.Create(messages); + + // Assert + Assert.Equal(3, index.Groups.Count); + Assert.Null(index.Groups[0].TurnIndex); // System + Assert.Equal(1, index.Groups[1].TurnIndex); // User turn 1 + Assert.Equal(1, index.Groups[2].TurnIndex); // ToolCall inherits turn 1 + Assert.Equal(MessageGroupKind.ToolCall, index.Groups[2].Kind); + Assert.Equal(3, index.Groups[2].MessageCount); // reasoning + toolCall + toolResult + } + + [Fact] + public void CreateAssistantWithMixedReasoningAndTextNotGroupedAsReasoning() + { + // Arrange — assistant with both reasoning and text content is NOT "only reasoning" + ChatMessage mixedAssistant = new(ChatRole.Assistant, [ + new TextReasoningContent("Thinking"), + new TextContent("And also speaking"), + ]); + ChatMessage toolCall = new(ChatRole.Assistant, [new FunctionCallContent("c1", "fn")]); + ChatMessage toolResult = new(ChatRole.Tool, [new FunctionResultContent("c1", "data")]); + + List messages = [mixedAssistant, toolCall, toolResult]; + + // Act + MessageIndex index = MessageIndex.Create(messages); + + // Assert — mixedAssistant has non-reasoning content, so it's AssistantText, not grouped with ToolCall + Assert.Equal(2, index.Groups.Count); + Assert.Equal(MessageGroupKind.AssistantText, index.Groups[0].Kind); + Assert.Equal(MessageGroupKind.ToolCall, index.Groups[1].Kind); + } + + [Fact] + public void CreateEmptyContentsAssistantIsAssistantText() + { + // Arrange — assistant message with empty contents (edge case for HasOnlyReasoning) + ChatMessage emptyAssistant = new(ChatRole.Assistant, []); + ChatMessage user = new(ChatRole.User, "Hello"); + + List messages = [emptyAssistant, user]; + + // Act + MessageIndex index = MessageIndex.Create(messages); + + // Assert — empty contents falls through to AssistantText + Assert.Equal(2, index.Groups.Count); + Assert.Equal(MessageGroupKind.AssistantText, index.Groups[0].Kind); + } + + [Fact] + public void UpdateIncrementallyAppendsReasoningToolCallGroup() + { + // Arrange — create initial index, then add reasoning+tool-call messages + List messages = + [ + new ChatMessage(ChatRole.User, "Hello"), + new ChatMessage(ChatRole.Assistant, "Hi!"), + ]; + MessageIndex index = MessageIndex.Create(messages); + Assert.Equal(2, index.Groups.Count); + + // Add reasoning + tool-call + messages.Add(new ChatMessage(ChatRole.Assistant, [new TextReasoningContent("Let me search")])); + messages.Add(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "search")])); + messages.Add(new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c1", "found")])); + + // Act + index.Update(messages); + + // Assert — new messages form a single ToolCall group (delta append) + Assert.Equal(3, index.Groups.Count); + Assert.Equal(MessageGroupKind.User, index.Groups[0].Kind); + Assert.Equal(MessageGroupKind.AssistantText, index.Groups[1].Kind); + Assert.Equal(MessageGroupKind.ToolCall, index.Groups[2].Kind); + Assert.Equal(3, index.Groups[2].MessageCount); // reasoning + toolCall + toolResult + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/SummarizationCompactionStrategyTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/SummarizationCompactionStrategyTests.cs index 9d2f4b25b2..a8df3ada21 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/SummarizationCompactionStrategyTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/SummarizationCompactionStrategyTests.cs @@ -1,7 +1,9 @@ // Copyright (c) Microsoft. All rights reserved. +using System; using System.Collections.Generic; using System.Linq; +using System.Net.Http; using System.Threading; using System.Threading.Tasks; using Microsoft.Agents.AI.Compaction; @@ -411,4 +413,201 @@ public async Task CompactAsyncWithEmptyTextMessageInGroupAsync() // Assert — compaction succeeded despite null text Assert.True(result); } + + #region Error resilience + + [Fact] + public async Task CompactAsyncLlmFailureRestoresGroupsAsync() + { + // Arrange — chat client throws a non-cancellation exception + Mock mockClient = new(); + mockClient.Setup(c => c.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .ThrowsAsync(new InvalidOperationException("Service unavailable")); + + SummarizationCompactionStrategy strategy = new( + mockClient.Object, + CompactionTriggers.Always, + minimumPreserved: 1); + + MessageIndex index = MessageIndex.Create( + [ + new ChatMessage(ChatRole.User, "Q1"), + new ChatMessage(ChatRole.Assistant, "A1"), + new ChatMessage(ChatRole.User, "Q2"), + ]); + + int originalGroupCount = index.Groups.Count; + + // Act + bool result = await strategy.CompactAsync(index); + + // Assert — returns false, all groups restored to non-excluded + Assert.False(result); + Assert.Equal(originalGroupCount, index.Groups.Count); + Assert.All(index.Groups, g => Assert.False(g.IsExcluded)); + Assert.All(index.Groups, g => Assert.Null(g.ExcludeReason)); + } + + [Fact] + public async Task CompactAsyncLlmFailurePreservesAllOriginalMessagesAsync() + { + // Arrange — verify that after failure, GetIncludedMessages returns all original messages + Mock mockClient = new(); + mockClient.Setup(c => c.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .ThrowsAsync(new HttpRequestException("Timeout")); + + SummarizationCompactionStrategy strategy = new( + mockClient.Object, + 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"), + ]); + + List originalIncluded = [.. index.GetIncludedMessages()]; + + // Act + await strategy.CompactAsync(index); + + // Assert — all original messages still included + List afterIncluded = [.. index.GetIncludedMessages()]; + Assert.Equal(originalIncluded.Count, afterIncluded.Count); + for (int i = 0; i < originalIncluded.Count; i++) + { + Assert.Same(originalIncluded[i], afterIncluded[i]); + } + } + + [Fact] + public async Task CompactAsyncLlmFailureDoesNotInsertSummaryGroupAsync() + { + // Arrange + Mock mockClient = new(); + mockClient.Setup(c => c.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .ThrowsAsync(new InvalidOperationException("API error")); + + SummarizationCompactionStrategy strategy = new( + mockClient.Object, + CompactionTriggers.Always, + minimumPreserved: 1); + + MessageIndex index = MessageIndex.Create( + [ + new ChatMessage(ChatRole.User, "Q1"), + new ChatMessage(ChatRole.User, "Q2"), + ]); + + // Act + await strategy.CompactAsync(index); + + // Assert — no Summary group was inserted + Assert.DoesNotContain(index.Groups, g => g.Kind == MessageGroupKind.Summary); + } + + [Fact] + public async Task CompactAsyncCancellationPropagatesAsync() + { + // Arrange — OperationCanceledException should NOT be caught + Mock mockClient = new(); + mockClient.Setup(c => c.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .ThrowsAsync(new OperationCanceledException("Cancelled")); + + SummarizationCompactionStrategy strategy = new( + mockClient.Object, + CompactionTriggers.Always, + minimumPreserved: 1); + + MessageIndex index = MessageIndex.Create( + [ + new ChatMessage(ChatRole.User, "Q1"), + new ChatMessage(ChatRole.User, "Q2"), + ]); + + // Act & Assert — OperationCanceledException propagates + await Assert.ThrowsAsync( + () => strategy.CompactAsync(index).AsTask()); + } + + [Fact] + public async Task CompactAsyncTaskCancellationPropagatesAsync() + { + // Arrange — TaskCanceledException (subclass of OperationCanceledException) should also propagate + Mock mockClient = new(); + mockClient.Setup(c => c.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .ThrowsAsync(new TaskCanceledException("Task cancelled")); + + SummarizationCompactionStrategy strategy = new( + mockClient.Object, + CompactionTriggers.Always, + minimumPreserved: 1); + + MessageIndex index = MessageIndex.Create( + [ + new ChatMessage(ChatRole.User, "Q1"), + new ChatMessage(ChatRole.User, "Q2"), + ]); + + // Act & Assert — TaskCanceledException propagates (inherits from OperationCanceledException) + await Assert.ThrowsAsync( + () => strategy.CompactAsync(index).AsTask()); + } + + [Fact] + public async Task CompactAsyncLlmFailureWithMultipleExcludedGroupsRestoresAllAsync() + { + // Arrange — multiple groups excluded before failure, all must be restored + Mock mockClient = new(); + mockClient.Setup(c => c.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .ThrowsAsync(new InvalidOperationException("Rate limited")); + + SummarizationCompactionStrategy strategy = new( + mockClient.Object, + CompactionTriggers.Always, + minimumPreserved: 1, + target: _ => false); // Never stop — exclude as many as possible + + 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"), + new ChatMessage(ChatRole.Assistant, "A2"), + new ChatMessage(ChatRole.User, "Q3"), + ]); + + // Act + bool result = await strategy.CompactAsync(index); + + // Assert — all non-system groups restored + Assert.False(result); + Assert.All(index.Groups, g => Assert.False(g.IsExcluded)); + Assert.All(index.Groups, g => Assert.Null(g.ExcludeReason)); + Assert.Equal(6, index.IncludedGroupCount); + } + + #endregion } From 6d663f2ece7ebdc5374edcea4204f50657614e9e Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Tue, 10 Mar 2026 15:37:21 -0700 Subject: [PATCH 72/80] Fix merge --- .../Compaction/CompactionMessageIndex.cs | 4 +- .../SummarizationCompactionStrategy.cs | 2 +- .../Compaction/CompactionMessageIndexTests.cs | 64 +++++++++---------- .../SummarizationCompactionStrategyTests.cs | 26 ++++---- 4 files changed, 48 insertions(+), 48 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionMessageIndex.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionMessageIndex.cs index 14cc6b0b6f..003a70f2b3 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionMessageIndex.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionMessageIndex.cs @@ -247,11 +247,11 @@ private void AppendFromMessages(IList messages, int startIndex) index++; } - this.Groups.Add(CreateGroup(MessageGroupKind.ToolCall, groupMessages, this.Tokenizer, this._currentTurn)); + this.Groups.Add(CreateGroup(CompactionGroupKind.ToolCall, groupMessages, this.Tokenizer, this._currentTurn)); } else { - this.Groups.Add(CreateGroup(MessageGroupKind.AssistantText, [message], this.Tokenizer, this._currentTurn)); + this.Groups.Add(CreateGroup(CompactionGroupKind.AssistantText, [message], this.Tokenizer, this._currentTurn)); index++; } } diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/SummarizationCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/SummarizationCompactionStrategy.cs index 04bb703018..4948d1b25c 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/SummarizationCompactionStrategy.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/SummarizationCompactionStrategy.cs @@ -129,7 +129,7 @@ protected override async ValueTask CompactCoreAsync(CompactionMessageIndex // Mark oldest non-system groups for summarization one at a time until the target is met. // Track which groups were excluded so we can restore them if the LLM call fails. List summarizationMessages = [new ChatMessage(ChatRole.System, this.SummarizationPrompt)]; - List excludedGroups = []; + List excludedGroups = []; List excludedGroupIndices = []; int insertIndex = -1; diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionMessageIndexTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionMessageIndexTests.cs index 5353a9ee93..ea0ecd0d44 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionMessageIndexTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionMessageIndexTests.cs @@ -279,7 +279,7 @@ public void MessageGroupMessagesAreImmutable() CompactionMessageGroup group = new(CompactionGroupKind.User, messages, byteCount: 5, tokenCount: 1); // Assert — Messages is IReadOnlyList, not IList - Assert.IsAssignableFrom>(group.Messages); + Assert.IsType>(group.Messages, exactMatch: false); Assert.Same(messages, group.Messages); } @@ -1182,7 +1182,7 @@ public void ComputeTokenCountMixedTextAndNonTextContent() [Fact] public void CreateGroupByteCountIncludesAllContentTypes() { - // Verify that MessageIndex.Create produces groups with accurate byte counts for non-text content + // Verify that CompactionMessageIndex.Create produces groups with accurate byte counts for non-text content ChatMessage assistantMessage = new(ChatRole.Assistant, [new FunctionCallContent("call1", "get_weather", new Dictionary { ["city"] = "Seattle" })]); ChatMessage toolResult = new(ChatRole.Tool, [new FunctionResultContent("call1", "Sunny")]); List messages = [assistantMessage, toolResult]; @@ -1254,11 +1254,11 @@ public void CreateReasoningBeforeToolCallGroupsAtomic() List messages = [reasoning, toolCall, toolResult]; // Act - MessageIndex index = MessageIndex.Create(messages); + CompactionMessageIndex index = CompactionMessageIndex.Create(messages); // Assert — all three messages in a single ToolCall group Assert.Single(index.Groups); - Assert.Equal(MessageGroupKind.ToolCall, index.Groups[0].Kind); + Assert.Equal(CompactionGroupKind.ToolCall, index.Groups[0].Kind); Assert.Equal(3, index.Groups[0].MessageCount); Assert.Same(reasoning, index.Groups[0].Messages[0]); Assert.Same(toolCall, index.Groups[0].Messages[1]); @@ -1277,11 +1277,11 @@ public void CreateMultipleReasoningBeforeToolCallGroupsAtomic() List messages = [reasoning1, reasoning2, toolCall, toolResult]; // Act - MessageIndex index = MessageIndex.Create(messages); + CompactionMessageIndex index = CompactionMessageIndex.Create(messages); // Assert — all four messages in a single ToolCall group Assert.Single(index.Groups); - Assert.Equal(MessageGroupKind.ToolCall, index.Groups[0].Kind); + Assert.Equal(CompactionGroupKind.ToolCall, index.Groups[0].Kind); Assert.Equal(4, index.Groups[0].MessageCount); } @@ -1295,12 +1295,12 @@ public void CreateReasoningNotFollowedByToolCallIsAssistantText() List messages = [reasoning, user]; // Act - MessageIndex index = MessageIndex.Create(messages); + CompactionMessageIndex index = CompactionMessageIndex.Create(messages); // Assert — reasoning becomes AssistantText, user stays User Assert.Equal(2, index.Groups.Count); - Assert.Equal(MessageGroupKind.AssistantText, index.Groups[0].Kind); - Assert.Equal(MessageGroupKind.User, index.Groups[1].Kind); + Assert.Equal(CompactionGroupKind.AssistantText, index.Groups[0].Kind); + Assert.Equal(CompactionGroupKind.User, index.Groups[1].Kind); } [Fact] @@ -1313,12 +1313,12 @@ public void CreateReasoningAtEndOfConversationIsAssistantText() List messages = [user, reasoning]; // Act - MessageIndex index = MessageIndex.Create(messages); + CompactionMessageIndex index = CompactionMessageIndex.Create(messages); // Assert Assert.Equal(2, index.Groups.Count); - Assert.Equal(MessageGroupKind.User, index.Groups[0].Kind); - Assert.Equal(MessageGroupKind.AssistantText, index.Groups[1].Kind); + Assert.Equal(CompactionGroupKind.User, index.Groups[0].Kind); + Assert.Equal(CompactionGroupKind.AssistantText, index.Groups[1].Kind); } [Fact] @@ -1332,11 +1332,11 @@ public void CreateToolCallFollowedByReasoningInTail() List messages = [toolCall, toolResult, reasoning]; // Act - MessageIndex index = MessageIndex.Create(messages); + CompactionMessageIndex index = CompactionMessageIndex.Create(messages); // Assert — reasoning after tool result should be included in the same ToolCall group Assert.Single(index.Groups); - Assert.Equal(MessageGroupKind.ToolCall, index.Groups[0].Kind); + Assert.Equal(CompactionGroupKind.ToolCall, index.Groups[0].Kind); Assert.Equal(3, index.Groups[0].MessageCount); } @@ -1355,14 +1355,14 @@ public void CreateReasoningBetweenToolCallsGroupsCorrectly() List messages = [reasoning1, toolCall1, toolResult1, user, reasoning2, toolCall2, toolResult2]; // Act - MessageIndex index = MessageIndex.Create(messages); + CompactionMessageIndex index = CompactionMessageIndex.Create(messages); // Assert — two ToolCall groups with reasoning included, plus one User group Assert.Equal(3, index.Groups.Count); - Assert.Equal(MessageGroupKind.ToolCall, index.Groups[0].Kind); + Assert.Equal(CompactionGroupKind.ToolCall, index.Groups[0].Kind); Assert.Equal(3, index.Groups[0].MessageCount); // reasoning1 + toolCall1 + toolResult1 - Assert.Equal(MessageGroupKind.User, index.Groups[1].Kind); - Assert.Equal(MessageGroupKind.ToolCall, index.Groups[2].Kind); + Assert.Equal(CompactionGroupKind.User, index.Groups[1].Kind); + Assert.Equal(CompactionGroupKind.ToolCall, index.Groups[2].Kind); Assert.Equal(3, index.Groups[2].MessageCount); // reasoning2 + toolCall2 + toolResult2 } @@ -1376,12 +1376,12 @@ public void CreateReasoningFollowedByNonReasoningAssistantNotGrouped() List messages = [reasoning, plainAssistant]; // Act - MessageIndex index = MessageIndex.Create(messages); + CompactionMessageIndex index = CompactionMessageIndex.Create(messages); // Assert — each becomes its own AssistantText group Assert.Equal(2, index.Groups.Count); - Assert.Equal(MessageGroupKind.AssistantText, index.Groups[0].Kind); - Assert.Equal(MessageGroupKind.AssistantText, index.Groups[1].Kind); + Assert.Equal(CompactionGroupKind.AssistantText, index.Groups[0].Kind); + Assert.Equal(CompactionGroupKind.AssistantText, index.Groups[1].Kind); } [Fact] @@ -1397,14 +1397,14 @@ public void CreateMixedReasoningAndToolCallTurnIndex() List messages = [system, user, reasoning, toolCall, toolResult]; // Act - MessageIndex index = MessageIndex.Create(messages); + CompactionMessageIndex index = CompactionMessageIndex.Create(messages); // Assert Assert.Equal(3, index.Groups.Count); Assert.Null(index.Groups[0].TurnIndex); // System Assert.Equal(1, index.Groups[1].TurnIndex); // User turn 1 Assert.Equal(1, index.Groups[2].TurnIndex); // ToolCall inherits turn 1 - Assert.Equal(MessageGroupKind.ToolCall, index.Groups[2].Kind); + Assert.Equal(CompactionGroupKind.ToolCall, index.Groups[2].Kind); Assert.Equal(3, index.Groups[2].MessageCount); // reasoning + toolCall + toolResult } @@ -1422,12 +1422,12 @@ public void CreateAssistantWithMixedReasoningAndTextNotGroupedAsReasoning() List messages = [mixedAssistant, toolCall, toolResult]; // Act - MessageIndex index = MessageIndex.Create(messages); + CompactionMessageIndex index = CompactionMessageIndex.Create(messages); // Assert — mixedAssistant has non-reasoning content, so it's AssistantText, not grouped with ToolCall Assert.Equal(2, index.Groups.Count); - Assert.Equal(MessageGroupKind.AssistantText, index.Groups[0].Kind); - Assert.Equal(MessageGroupKind.ToolCall, index.Groups[1].Kind); + Assert.Equal(CompactionGroupKind.AssistantText, index.Groups[0].Kind); + Assert.Equal(CompactionGroupKind.ToolCall, index.Groups[1].Kind); } [Fact] @@ -1440,11 +1440,11 @@ public void CreateEmptyContentsAssistantIsAssistantText() List messages = [emptyAssistant, user]; // Act - MessageIndex index = MessageIndex.Create(messages); + CompactionMessageIndex index = CompactionMessageIndex.Create(messages); // Assert — empty contents falls through to AssistantText Assert.Equal(2, index.Groups.Count); - Assert.Equal(MessageGroupKind.AssistantText, index.Groups[0].Kind); + Assert.Equal(CompactionGroupKind.AssistantText, index.Groups[0].Kind); } [Fact] @@ -1456,7 +1456,7 @@ public void UpdateIncrementallyAppendsReasoningToolCallGroup() new ChatMessage(ChatRole.User, "Hello"), new ChatMessage(ChatRole.Assistant, "Hi!"), ]; - MessageIndex index = MessageIndex.Create(messages); + CompactionMessageIndex index = CompactionMessageIndex.Create(messages); Assert.Equal(2, index.Groups.Count); // Add reasoning + tool-call @@ -1469,9 +1469,9 @@ public void UpdateIncrementallyAppendsReasoningToolCallGroup() // Assert — new messages form a single ToolCall group (delta append) Assert.Equal(3, index.Groups.Count); - Assert.Equal(MessageGroupKind.User, index.Groups[0].Kind); - Assert.Equal(MessageGroupKind.AssistantText, index.Groups[1].Kind); - Assert.Equal(MessageGroupKind.ToolCall, index.Groups[2].Kind); + Assert.Equal(CompactionGroupKind.User, index.Groups[0].Kind); + Assert.Equal(CompactionGroupKind.AssistantText, index.Groups[1].Kind); + Assert.Equal(CompactionGroupKind.ToolCall, index.Groups[2].Kind); Assert.Equal(3, index.Groups[2].MessageCount); // reasoning + toolCall + toolResult } } diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/SummarizationCompactionStrategyTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/SummarizationCompactionStrategyTests.cs index 09fb4330be..2ab000e544 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/SummarizationCompactionStrategyTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/SummarizationCompactionStrategyTests.cs @@ -430,9 +430,9 @@ public async Task CompactAsyncLlmFailureRestoresGroupsAsync() SummarizationCompactionStrategy strategy = new( mockClient.Object, CompactionTriggers.Always, - minimumPreserved: 1); + minimumPreservedGroups: 1); - MessageIndex index = MessageIndex.Create( + CompactionMessageIndex index = CompactionMessageIndex.Create( [ new ChatMessage(ChatRole.User, "Q1"), new ChatMessage(ChatRole.Assistant, "A1"), @@ -465,9 +465,9 @@ public async Task CompactAsyncLlmFailurePreservesAllOriginalMessagesAsync() SummarizationCompactionStrategy strategy = new( mockClient.Object, CompactionTriggers.Always, - minimumPreserved: 1); + minimumPreservedGroups: 1); - MessageIndex index = MessageIndex.Create( + CompactionMessageIndex index = CompactionMessageIndex.Create( [ new ChatMessage(ChatRole.User, "Q1"), new ChatMessage(ChatRole.Assistant, "A1"), @@ -503,9 +503,9 @@ public async Task CompactAsyncLlmFailureDoesNotInsertSummaryGroupAsync() SummarizationCompactionStrategy strategy = new( mockClient.Object, CompactionTriggers.Always, - minimumPreserved: 1); + minimumPreservedGroups: 1); - MessageIndex index = MessageIndex.Create( + CompactionMessageIndex index = CompactionMessageIndex.Create( [ new ChatMessage(ChatRole.User, "Q1"), new ChatMessage(ChatRole.User, "Q2"), @@ -515,7 +515,7 @@ public async Task CompactAsyncLlmFailureDoesNotInsertSummaryGroupAsync() await strategy.CompactAsync(index); // Assert — no Summary group was inserted - Assert.DoesNotContain(index.Groups, g => g.Kind == MessageGroupKind.Summary); + Assert.DoesNotContain(index.Groups, g => g.Kind == CompactionGroupKind.Summary); } [Fact] @@ -532,9 +532,9 @@ public async Task CompactAsyncCancellationPropagatesAsync() SummarizationCompactionStrategy strategy = new( mockClient.Object, CompactionTriggers.Always, - minimumPreserved: 1); + minimumPreservedGroups: 1); - MessageIndex index = MessageIndex.Create( + CompactionMessageIndex index = CompactionMessageIndex.Create( [ new ChatMessage(ChatRole.User, "Q1"), new ChatMessage(ChatRole.User, "Q2"), @@ -559,9 +559,9 @@ public async Task CompactAsyncTaskCancellationPropagatesAsync() SummarizationCompactionStrategy strategy = new( mockClient.Object, CompactionTriggers.Always, - minimumPreserved: 1); + minimumPreservedGroups: 1); - MessageIndex index = MessageIndex.Create( + CompactionMessageIndex index = CompactionMessageIndex.Create( [ new ChatMessage(ChatRole.User, "Q1"), new ChatMessage(ChatRole.User, "Q2"), @@ -586,10 +586,10 @@ public async Task CompactAsyncLlmFailureWithMultipleExcludedGroupsRestoresAllAsy SummarizationCompactionStrategy strategy = new( mockClient.Object, CompactionTriggers.Always, - minimumPreserved: 1, + minimumPreservedGroups: 1, target: _ => false); // Never stop — exclude as many as possible - MessageIndex index = MessageIndex.Create( + CompactionMessageIndex index = CompactionMessageIndex.Create( [ new ChatMessage(ChatRole.System, "System prompt"), new ChatMessage(ChatRole.User, "Q1"), From 6fbe1ecf69422bcddf541d5a3baebfd6438ca34b Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Tue, 10 Mar 2026 16:01:47 -0700 Subject: [PATCH 73/80] Fix equality, readme, and sample --- .../Agents/Agent_Step18_CompactionPipeline/Program.cs | 2 +- .../02-agents/Agents/Agent_Step18_CompactionPipeline/README.md | 2 +- .../Compaction/ChatMessageContentEquality.cs | 3 ++- 3 files changed, 4 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 09e948d31c..034fe00496 100644 --- a/dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/Program.cs +++ b/dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/Program.cs @@ -88,7 +88,7 @@ void PrintChatHistory() if (session.TryGetInMemoryChatHistory(out var history)) { Console.ForegroundColor = ConsoleColor.Cyan; - Console.WriteLine($"\n[Messages: x{history.Count}]\n"); + Console.WriteLine($"\n[Messages: #{history.Count}]\n"); Console.ResetColor(); } } diff --git a/dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/README.md b/dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/README.md index db3969a825..c30d525884 100644 --- a/dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/README.md +++ b/dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/README.md @@ -78,7 +78,7 @@ dotnet run ## Expected Behavior -The sample runs a seven-turn shopping-assistant conversation with tool calls. After each turn it prints the current in-memory message count so you can observe the pipeline compacting the history as the conversation grows. +The sample runs a seven-turn shopping-assistant conversation with tool calls. After each turn it prints the full message count so you can observe the pipeline compaction doesn't alter the source conversation. Each of the four compaction strategies has a deliberately low threshold so that it activates during the short demonstration conversation. In a production scenario you would raise the thresholds to match your model's context window and cost requirements. diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/ChatMessageContentEquality.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/ChatMessageContentEquality.cs index 8225490535..6e325cd8b8 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/ChatMessageContentEquality.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/ChatMessageContentEquality.cs @@ -112,7 +112,8 @@ private static bool UriContentEquals(UriContent a, UriContent b) => private static bool ErrorContentEquals(ErrorContent a, ErrorContent b) => string.Equals(a.Message, b.Message, StringComparison.Ordinal) && - string.Equals(a.ErrorCode, b.ErrorCode, StringComparison.Ordinal); + string.Equals(a.ErrorCode, b.ErrorCode, StringComparison.Ordinal) && + Equals(a.Details, b.Details); private static bool FunctionCallContentEquals(FunctionCallContent a, FunctionCallContent b) => string.Equals(a.CallId, b.CallId, StringComparison.Ordinal) && From 3cdb4c07b6e6873e63794d307fb843914af3881b Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Tue, 10 Mar 2026 16:42:52 -0700 Subject: [PATCH 74/80] Readme update and ToolResult fix --- .../Agent_Step18_CompactionPipeline/README.md | 6 +- .../ToolResultCompactionStrategy.cs | 119 +++++++++++++++--- .../ToolResultCompactionStrategyTests.cs | 94 +++++++++++++- 3 files changed, 195 insertions(+), 24 deletions(-) diff --git a/dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/README.md b/dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/README.md index c30d525884..74532743eb 100644 --- a/dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/README.md +++ b/dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/README.md @@ -114,7 +114,7 @@ new SummarizationCompactionStrategy(summarizerChatClient, CompactionTriggers.Tok ### Registering through `ChatClientAgentOptions` -`AIContextProviders` can also be specified directly on `ChatClientAgentOptions` instead of calling `UseAIContextProviders` on the builder: +`CompactionProvider` can also be specified directly on `ChatClientAgentOptions` instead of calling `UseAIContextProviders` on the `ChatClientBuilder`: ```csharp AIAgent agent = agentChatClient @@ -124,3 +124,7 @@ AIAgent agent = agentChatClient AIContextProviders = [new CompactionProvider(compactionPipeline)] }); ``` + +This places the compaction provider at the agent level instead of the chat client level, which allows you to use different compaction strategies for different agents that share the same chat client. + +> Note: In this mode the `CompactionProvider` is not engaged during the tool calling loop. diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/ToolResultCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/ToolResultCompactionStrategy.cs index c9abcf6499..9b4dbb6b16 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/ToolResultCompactionStrategy.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/ToolResultCompactionStrategy.cs @@ -12,15 +12,22 @@ 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. +/// messages, removing the detailed tool results while preserving a record of which tools were called +/// and what they returned. /// /// /// /// 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]. +/// (assistant call + tool results) with a single assistant message in a YAML-like format: +/// +/// [Tool Calls] +/// get_weather: +/// - Sunny and 72°F +/// search_docs: +/// - Found 3 docs +/// /// /// /// is a hard floor: even if the @@ -113,28 +120,12 @@ protected override ValueTask CompactCoreAsync(CompactionMessageIndex index int idx = eligibleIndices[e] + offset; CompactionMessageGroup group = index.Groups[idx]; - // 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); - } - } - } - } + string summary = BuildToolCallSummary(group); // 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)}]"; - ChatMessage summaryMessage = new(ChatRole.Assistant, summary); (summaryMessage.AdditionalProperties ??= [])[CompactionMessageGroup.SummaryPropertyKey] = true; @@ -152,4 +143,92 @@ protected override ValueTask CompactCoreAsync(CompactionMessageIndex index return new ValueTask(compacted); } + + /// + /// Builds a concise summary string for a tool call group, including tool names, + /// results, and deduplication counts for repeated tool names. + /// + private static string BuildToolCallSummary(CompactionMessageGroup group) + { + // Collect function calls (callId, name) and results (callId → result text) + List<(string CallId, string Name)> functionCalls = []; + Dictionary resultsByCallId = new(); + List plainTextResults = []; + + foreach (ChatMessage message in group.Messages) + { + if (message.Contents is null) + { + continue; + } + + bool hasFunctionResult = false; + foreach (AIContent content in message.Contents) + { + if (content is FunctionCallContent fcc) + { + functionCalls.Add((fcc.CallId, fcc.Name)); + } + else if (content is FunctionResultContent frc && frc.CallId is not null) + { + resultsByCallId[frc.CallId] = frc.Result?.ToString() ?? string.Empty; + hasFunctionResult = true; + } + } + + // Collect plain text from Tool-role messages that lack FunctionResultContent + if (!hasFunctionResult && message.Role == ChatRole.Tool && message.Text is string text) + { + plainTextResults.Add(text); + } + } + + // Match function calls to their results using CallId or positional fallback, + // grouping by tool name while preserving first-seen order. + int plainTextIdx = 0; + List orderedNames = []; + Dictionary> groupedResults = new(); + + foreach ((string callId, string name) in functionCalls) + { + if (!groupedResults.TryGetValue(name, out _)) + { + orderedNames.Add(name); + groupedResults[name] = []; + } + + string? result = null; + if (resultsByCallId.TryGetValue(callId, out string? matchedResult)) + { + result = matchedResult; + } + else if (plainTextIdx < plainTextResults.Count) + { + result = plainTextResults[plainTextIdx++]; + } + + if (!string.IsNullOrEmpty(result)) + { + groupedResults[name].Add(result); + } + } + + // Format as YAML-like block with [Tool Calls] header + List lines = ["[Tool Calls]"]; + foreach (string name in orderedNames) + { + List results = groupedResults[name]; + + lines.Add($"{name}:"); + if (results.Count > 0) + { + foreach (string result in results) + { + lines.Add($" - {result}"); + } + } + } + + return string.Join("\n", lines); + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/ToolResultCompactionStrategyTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/ToolResultCompactionStrategyTests.cs index d06a791450..b941439988 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/ToolResultCompactionStrategyTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/ToolResultCompactionStrategyTests.cs @@ -61,7 +61,7 @@ public async Task CompactAsyncCollapsesOldToolGroupsAsync() // 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("[Tool Calls]\nget_weather:\n - Sunny and 72°F", included[1].Text); Assert.Equal("Q2", included[2].Text); } @@ -142,8 +142,7 @@ public async Task CompactAsyncExtractsMultipleToolNamesAsync() // Assert List included = [.. groups.GetIncludedMessages()]; string collapsed = included[1].Text!; - Assert.Contains("get_weather", collapsed); - Assert.Contains("search_docs", collapsed); + Assert.Equal("[Tool Calls]\nget_weather:\n - Sunny\nsearch_docs:\n - Found 3 docs", collapsed); } [Fact] @@ -260,4 +259,93 @@ public async Task CompactAsyncSkipsPreExcludedAndSystemGroupsAsync() Assert.True(result); Assert.False(index.Groups[0].IsExcluded); // System stays } + + [Fact] + public async Task CompactAsyncDeduplicatesDuplicateToolNamesAsync() + { + // Arrange — same tool called multiple times + ToolResultCompactionStrategy strategy = new( + trigger: _ => true, + minimumPreservedGroups: 1); + + CompactionMessageIndex groups = CompactionMessageIndex.Create( + [ + new ChatMessage(ChatRole.User, "Q1"), + new ChatMessage(ChatRole.Assistant, + [ + new FunctionCallContent("c1", "get_weather"), + new FunctionCallContent("c2", "get_weather"), + ]), + new ChatMessage(ChatRole.Tool, "Sunny"), + new ChatMessage(ChatRole.Tool, "Rainy"), + new ChatMessage(ChatRole.User, "Q2"), + ]); + + // Act + await strategy.CompactAsync(groups); + + // Assert — duplicate names listed once with all results + List included = [.. groups.GetIncludedMessages()]; + Assert.Equal("[Tool Calls]\nget_weather:\n - Sunny\n - Rainy", included[1].Text); + } + + [Fact] + public async Task CompactAsyncIncludesResultsFromFunctionResultContentAsync() + { + // Arrange — tool results provided as FunctionResultContent (matched by CallId) + ToolResultCompactionStrategy strategy = new( + trigger: _ => true, + minimumPreservedGroups: 1); + + CompactionMessageIndex groups = CompactionMessageIndex.Create( + [ + new ChatMessage(ChatRole.User, "Q1"), + new ChatMessage(ChatRole.Assistant, + [ + new FunctionCallContent("c1", "get_weather"), + new FunctionCallContent("c2", "search_docs"), + ]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c1", "Sunny and 72°F")]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c2", "Found 3 docs")]), + new ChatMessage(ChatRole.User, "Q2"), + ]); + + // Act + await strategy.CompactAsync(groups); + + // Assert — results matched by CallId and included in summary + List included = [.. groups.GetIncludedMessages()]; + Assert.Equal("[Tool Calls]\nget_weather:\n - Sunny and 72°F\nsearch_docs:\n - Found 3 docs", included[1].Text); + } + + [Fact] + public async Task CompactAsyncDeduplicatesWithFunctionResultContentAsync() + { + // Arrange — same tool called multiple times with FunctionResultContent + ToolResultCompactionStrategy strategy = new( + trigger: _ => true, + minimumPreservedGroups: 1); + + CompactionMessageIndex groups = CompactionMessageIndex.Create( + [ + new ChatMessage(ChatRole.User, "Q1"), + new ChatMessage(ChatRole.Assistant, + [ + new FunctionCallContent("c1", "get_weather"), + new FunctionCallContent("c2", "get_weather"), + new FunctionCallContent("c3", "search_docs"), + ]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c1", "Sunny")]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c2", "Rainy")]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c3", "Found 3 docs")]), + new ChatMessage(ChatRole.User, "Q2"), + ]); + + // Act + await strategy.CompactAsync(groups); + + // Assert — duplicate tool name results listed under same key + List included = [.. groups.GetIncludedMessages()]; + Assert.Equal("[Tool Calls]\nget_weather:\n - Sunny\n - Rainy\nsearch_docs:\n - Found 3 docs", included[1].Text); + } } From 45d689b2b011ab8466e674ff503b060e4bba3ca3 Mon Sep 17 00:00:00 2001 From: Chris <66376200+crickman@users.noreply.github.com> Date: Tue, 10 Mar 2026 16:58:25 -0700 Subject: [PATCH 75/80] Update dotnet/src/Microsoft.Agents.AI/Compaction/SummarizationCompactionStrategy.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../Compaction/SummarizationCompactionStrategy.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/SummarizationCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/SummarizationCompactionStrategy.cs index 4948d1b25c..f6bcb8a880 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/SummarizationCompactionStrategy.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/SummarizationCompactionStrategy.cs @@ -152,7 +152,6 @@ protected override async ValueTask CompactCoreAsync(CompactionMessageIndex group.IsExcluded = true; group.ExcludeReason = $"Summarized by {nameof(SummarizationCompactionStrategy)}"; excludedGroups.Add(group); - excludedGroupIndices.Add(i); // Stop marking when target condition is met if (this.Target(index)) From e79cfd136c5f3e7a9e296db5ea51ae927099c68b Mon Sep 17 00:00:00 2001 From: Chris <66376200+crickman@users.noreply.github.com> Date: Tue, 10 Mar 2026 16:59:05 -0700 Subject: [PATCH 76/80] Update dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/README.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../02-agents/Agents/Agent_Step18_CompactionPipeline/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/README.md b/dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/README.md index 74532743eb..c4db983141 100644 --- a/dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/README.md +++ b/dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/README.md @@ -127,4 +127,4 @@ AIAgent agent = agentChatClient This places the compaction provider at the agent level instead of the chat client level, which allows you to use different compaction strategies for different agents that share the same chat client. -> Note: In this mode the `CompactionProvider` is not engaged during the tool calling loop. +> Note: In this mode the `CompactionProvider` is not engaged during the tool calling loop. Agent-level `AIContextProviders` run before chat history is stored, so any synthetic summary messages produced by `CompactionProvider` can become part of the persisted history when using `ChatHistoryProvider`. If you want to compact only the request context while preserving the original stored history, register `CompactionProvider` on the `ChatClientBuilder` via `UseAIContextProviders(...)` instead of on `ChatClientAgentOptions`. From 5023ffc5d0673d01b3ca06db456c593fde6d0aad Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Tue, 10 Mar 2026 17:00:11 -0700 Subject: [PATCH 77/80] Simplify readme --- .../02-agents/Agents/Agent_Step18_CompactionPipeline/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/README.md b/dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/README.md index 74532743eb..dd8cc6ce88 100644 --- a/dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/README.md +++ b/dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/README.md @@ -6,7 +6,7 @@ This sample demonstrates how to use a `CompactionProvider` with a `PipelineCompa - **`CompactionProvider`** — an `AIContextProvider` that applies a compaction strategy before each agent invocation, keeping only the most relevant messages within the model's context window - **`PipelineCompactionStrategy`** — chains multiple compaction strategies into an ordered pipeline; each strategy evaluates its own trigger independently and operates on the output of the previous one -- **`ToolResultCompactionStrategy`** — collapses older tool-call groups into concise inline summaries (e.g., `[Tool calls: LookupPrice]`), activated by a message-count trigger +- **`ToolResultCompactionStrategy`** — collapses older tool-call groups into concise inline summaries, activated by a message-count trigger - **`SummarizationCompactionStrategy`** — uses an LLM to compress older conversation spans into a single summary message, activated by a token-count trigger - **`SlidingWindowCompactionStrategy`** — retains only the most recent N user turns and their responses, activated by a turn-count trigger - **`TruncationCompactionStrategy`** — emergency backstop that drops the oldest groups until the conversation fits within a hard token budget From e4beb8414bd99db594d3316dd5cfb772784144f4 Mon Sep 17 00:00:00 2001 From: Chris <66376200+crickman@users.noreply.github.com> Date: Tue, 10 Mar 2026 17:06:04 -0700 Subject: [PATCH 78/80] Update dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/README.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../02-agents/Agents/Agent_Step18_CompactionPipeline/README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/README.md b/dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/README.md index 2679100d26..0640a42f21 100644 --- a/dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/README.md +++ b/dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/README.md @@ -24,7 +24,9 @@ The compaction engine organizes messages into atomic *groups* that are treated a | `User` | A single user message | | `ToolCall` | One assistant message with tool calls + the matching tool result messages | | `AssistantText` | A single assistant text-only message | +| `Summary` | One or more messages summarizing earlier conversation spans, produced by compaction strategies | +`Summary` groups (`CompactionGroupKind.Summary`) are created by compaction strategies (for example, `SummarizationCompactionStrategy`) and do not originate directly from user or assistant messages. Strategies exclude entire groups rather than individual messages, preserving the tool-call/result pairing required by most model APIs. ### Compaction triggers From 07c9af7e6694ad51d2123a2846e106f152e62cef Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Tue, 10 Mar 2026 17:07:02 -0700 Subject: [PATCH 79/80] Remove example --- .../02-agents/Agents/Agent_Step18_CompactionPipeline/Program.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 034fe00496..ce0a4a294d 100644 --- a/dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/Program.cs +++ b/dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/Program.cs @@ -41,7 +41,7 @@ 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. PipelineCompactionStrategy compactionPipeline = - new(// 1. Gentle: collapse old tool-call groups into short summaries like "[Tool calls: LookupPrice]" + new(// 1. Gentle: collapse old tool-call groups into short summaries new ToolResultCompactionStrategy(CompactionTriggers.MessagesExceed(7)), // 2. Moderate: use an LLM to summarize older conversation spans into a concise message From f75f796cb7d84f8574b65cc636066916b5cc7ce1 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Tue, 10 Mar 2026 17:08:19 -0700 Subject: [PATCH 80/80] Remove unused --- .../Compaction/SummarizationCompactionStrategy.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/SummarizationCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/SummarizationCompactionStrategy.cs index f6bcb8a880..9ff7ecf405 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/SummarizationCompactionStrategy.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/SummarizationCompactionStrategy.cs @@ -130,7 +130,6 @@ protected override async ValueTask CompactCoreAsync(CompactionMessageIndex // Track which groups were excluded so we can restore them if the LLM call fails. List summarizationMessages = [new ChatMessage(ChatRole.System, this.SummarizationPrompt)]; List excludedGroups = []; - List excludedGroupIndices = []; int insertIndex = -1; for (int i = 0; i < index.Groups.Count && excludedGroups.Count < maxSummarizable; i++)