From 23cf75be3cb863a295636e82de2323fa96931e50 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Wed, 4 Mar 2026 18:12:23 -0800 Subject: [PATCH 01/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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)] });