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 @@ using Microsoft.Shared.Diagnostics; 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 @@ internal sealed class CompactingChatClient : DelegatingChatClient 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 @@ internal sealed class CompactingChatClient : DelegatingChatClient 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 @@ 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. +/// 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 sealed class SummarizationCompactionStrategy : ICompactionStrategy 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 sealed class SummarizationCompactionStrategy : ICompactionStrategy 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++) + { + MessageGroup group = index.Groups[i]; + if (!group.IsExcluded && group.Kind != MessageGroupKind.System) + { + nonSystemIncludedCount++; + } + } + + int protectedFromEnd = Math.Min(this.PreserveRecentGroups, nonSystemIncludedCount); + int groupsToSummarize = nonSystemIncludedCount - protectedFromEnd; + + if (groupsToSummarize <= 0) { 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++) + 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 sealed class SummarizationCompactionStrategy : ICompactionStrategy } group.IsExcluded = true; - group.ExcludeReason = "Summarized by SummarizationCompactionStrategy"; + group.ExcludeReason = $"Summarized by {nameof(SummarizationCompactionStrategy)}"; summarized++; } @@ -111,23 +149,27 @@ public sealed class SummarizationCompactionStrategy : ICompactionStrategy 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 @@ 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. +/// 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 { + /// + /// The default number of most-recent non-system groups to protect from collapsing. + /// + public const int DefaultPreserveRecentGroups = 32; + /// /// 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) + /// + /// 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.MaxGroups = maxGroups; - this.PreserveSystemMessages = preserveSystemMessages; + this.PreserveRecentGroups = preserveRecentGroups; } /// - /// Gets the maximum number of message groups to retain after compaction. + /// Gets the minimum number of most-recent non-system 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 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++) + { + MessageGroup group = index.Groups[i]; + if (!group.IsExcluded && group.Kind != MessageGroupKind.System) + { + removableCount++; + } + } + + int maxRemovable = removableCount - this.PreserveRecentGroups; + if (maxRemovable <= 0) { 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++) + 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,78 +12,91 @@ 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); - } - - [Fact] - public async Task CompactAsync_AtLimit_ReturnsFalseAsync() - { - // Arrange - TruncationCompactionStrategy strategy = new(maxGroups: 2); - 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_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"); - - MessageIndex groups = MessageIndex.Create([msg1, msg2, msg3, msg4]); - - // Act - bool result = await strategy.CompactAsync(groups); - // Assert Assert.True(result); + Assert.Equal(1, groups.Groups.Count(g => !g.IsExcluded)); + } + + [Fact] + public async Task CompactAsync_TriggerNotMet_ReturnsFalseAsync() + { + // 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"), + 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_TriggerMet_ExcludesOldestGroupsAsync() + { + // Arrange — trigger on groups > 2 + TruncationCompactionStrategy strategy = new( + preserveRecentGroups: 1, + trigger: CompactionTriggers.GroupsExceed(2)); + + 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(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 class TruncationCompactionStrategyTests // 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 class TruncationCompactionStrategyTests 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 class TruncationCompactionStrategyTests 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 class TruncationCompactionStrategyTests 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 class TruncationCompactionStrategyTests 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); + } }