diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index 255d8fe94f..5d4a96ea5d 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -108,6 +108,7 @@ + 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 @@ + 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..472dd4cb0a --- /dev/null +++ b/dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/Program.cs @@ -0,0 +1,112 @@ +// 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. +PipelineCompactionStrategy compactionPipeline = + new(// 1. Gentle: collapse old tool-call groups into short summaries like "[Tool calls: LookupPrice]" + new ToolResultCompactionStrategy(CompactionTriggers.TokensExceed(0x200)), + + // 2. Moderate: use an LLM to summarize older conversation spans into a concise message + new SummarizationCompactionStrategy(summarizerChatClient, CompactionTriggers.TokensExceed(0x500)), + + // 3. Aggressive: keep only the last N user turns and their responses + new SlidingWindowCompactionStrategy(CompactionTriggers.TurnsExceed(4)), + + // 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. +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.ForegroundColor = ConsoleColor.Cyan; + Console.WriteLine($"\n[Messages: x{history.Count}]\n"); + Console.ResetColor(); + } +} + +// 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.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(); +} 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 diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/InMemoryChatHistoryProvider.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/InMemoryChatHistoryProvider.cs index 7c7b28b7bd..539a81ff73 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/InMemoryChatHistoryProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/InMemoryChatHistoryProvider.cs @@ -79,20 +79,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; @@ -101,18 +102,31 @@ 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) + if (this.ReducerTriggerEvent is InMemoryChatHistoryProviderOptions.ChatReducerTriggerEvent.AfterMessageAdded) { - state.Messages = (await this.ChatReducer.ReduceAsync(state.Messages, cancellationToken).ConfigureAwait(false)).ToList(); + // Apply pre-write compaction strategy if configured + await this.CompactMessagesAsync(state, cancellationToken).ConfigureAwait(false); } } + private async Task CompactMessagesAsync(State state, CancellationToken cancellationToken = default) + { + if (this.ChatReducer is not null) + { + // ChatReducer takes precedence, if configured + state.Messages = [.. await this.ChatReducer.ReduceAsync(state.Messages, cancellationToken).ConfigureAwait(false)]; + return; + } + + // %%% TODO: CONSIDER COMPACTION + } + /// /// Represents the state of a stored in the . /// diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentOptions.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentOptions.cs index 38cad40bbe..affd21398f 100644 --- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentOptions.cs @@ -1,7 +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; @@ -45,6 +48,23 @@ 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. @@ -101,6 +121,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..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; @@ -53,9 +54,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/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/src/Microsoft.Agents.AI/Compaction/CompactingChatClient.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactingChatClient.cs new file mode 100644 index 0000000000..29ac2e0994 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactingChatClient.cs @@ -0,0 +1,141 @@ +// 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/CompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionStrategy.cs new file mode 100644 index 0000000000..489fb44e0a --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionStrategy.cs @@ -0,0 +1,130 @@ +// 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; + +/// +/// 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 . +/// +/// +/// 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. +/// 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 . +/// +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public abstract class CompactionStrategy +{ + /// + /// Initializes a new instance of the class. + /// + /// + /// The that determines whether compaction should proceed. + /// + /// + /// 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)); + } + + /// + /// Gets the trigger predicate that controls when compaction proceeds. + /// + 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. + /// + /// 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. 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 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..4803c6a6fa --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionTrigger.cs @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; +using Microsoft.Shared.DiagnosticIds; + +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. +[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 new file mode 100644 index 0000000000..53d3782a5f --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionTriggers.cs @@ -0,0 +1,117 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using Microsoft.Shared.DiagnosticIds; + +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. +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public static class CompactionTriggers +{ + /// + /// Always trigger compaction, regardless of the message index state. + /// + 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. + /// + /// 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. + /// + /// 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/Compaction/MessageGroup.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/MessageGroup.cs new file mode 100644 index 0000000000..df1098c7da --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/MessageGroup.cs @@ -0,0 +1,113 @@ +// 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; + +/// +/// 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. +/// +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +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). + /// + [JsonConstructor] + 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/Compaction/MessageGroupKind.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/MessageGroupKind.cs new file mode 100644 index 0000000000..74018d067b --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/MessageGroupKind.cs @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; +using Microsoft.Shared.DiagnosticIds; + +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. +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +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, + +#pragma warning disable IDE0001 // Simplify Names + /// + /// 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 . + /// +#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 new file mode 100644 index 0000000000..3de2efa390 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/MessageIndex.cs @@ -0,0 +1,376 @@ +// Copyright (c) Microsoft. All rights reserved. + +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; + +/// +/// 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. +/// +/// +/// 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. +/// +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public sealed class MessageIndex +{ + private int _currentTurn; + + /// + /// 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; } + + /// + /// 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 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[index].TurnIndex.HasValue) + { + this._currentTurn = this.Groups[index].TurnIndex!.Value; + break; + } + } + } + + /// + /// 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 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; + } + + /// + /// 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) + { + int index = startIndex; + + while (index < messages.Count) + { + ChatMessage message = messages[index]; + + if (message.Role == ChatRole.System) + { + // System messages are not part of any turn + this.Groups.Add(CreateGroup(MessageGroupKind.System, [message], this.Tokenizer, turnIndex: null)); + index++; + } + else if (message.Role == ChatRole.User) + { + this._currentTurn++; + this.Groups.Add(CreateGroup(MessageGroupKind.User, [message], this.Tokenizer, this._currentTurn)); + 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++; + } + + this.Groups.Add(CreateGroup(MessageGroupKind.ToolCall, groupMessages, this.Tokenizer, this._currentTurn)); + } + else if (message.Role == ChatRole.Assistant && IsSummaryMessage(message)) + { + this.Groups.Add(CreateGroup(MessageGroupKind.Summary, [message], this.Tokenizer, this._currentTurn)); + index++; + } + else + { + this.Groups.Add(CreateGroup(MessageGroupKind.AssistantText, [message], this.Tokenizer, this._currentTurn)); + index++; + } + } + + this.ProcessedMessageCount = messages.Count; + } + + /// + /// 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); + + /// + /// 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); + + /// + /// 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); + + /// + /// 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); + + /// + /// 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) + { + 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/Compaction/PipelineCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/PipelineCompactionStrategy.cs new file mode 100644 index 0000000000..fa23239156 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/PipelineCompactionStrategy.cs @@ -0,0 +1,62 @@ +// 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; + +/// +/// 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. +/// +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +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..11effccaa4 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/SlidingWindowCompactionStrategy.cs @@ -0,0 +1,133 @@ +// Copyright (c) Microsoft. All rights reserved. + +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; + +/// +/// 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 excludes the oldest turns +/// one at a time until the condition is met. +/// +/// +/// is a hard floor: even if the +/// has not been reached, compaction will not touch the last non-system groups. +/// +/// +/// 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. +/// +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public sealed class SlidingWindowCompactionStrategy : CompactionStrategy +{ + /// + /// The default minimum number of most-recent non-system groups to preserve. + /// + public const int DefaultMinimumPreserved = 1; + + /// + /// Initializes a new instance of the class. + /// + /// + /// 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 — compaction stops as soon as the trigger would no longer fire. + /// + public SlidingWindowCompactionStrategy(CompactionTrigger trigger, int minimumPreserved = DefaultMinimumPreserved, CompactionTrigger? target = null) + : base(trigger, target) + { + this.MinimumPreserved = minimumPreserved; + } + + /// + /// Gets the minimum number of most-recent non-system groups that are always preserved. + /// This is a hard floor that compaction cannot exceed, regardless of the target condition. + /// + public int MinimumPreserved { get; } + + /// + 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 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++) + { + 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 + bool compacted = false; + + for (int t = 0; t < excludableTurns.Count; 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 + && !protectedGroupIndices.Contains(i) + && group.TurnIndex == turnToExclude) + { + group.IsExcluded = true; + group.ExcludeReason = $"Excluded by {nameof(SlidingWindowCompactionStrategy)}"; + } + } + + 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/SummarizationCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/SummarizationCompactionStrategy.cs new file mode 100644 index 0000000000..d87a9e63a7 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/SummarizationCompactionStrategy.cs @@ -0,0 +1,185 @@ +// 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; + +/// +/// 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. +/// +/// +/// +/// 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. +/// +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public sealed class SummarizationCompactionStrategy : CompactionStrategy +{ + /// + /// 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 to use for generating summaries. A smaller, faster model is recommended. + /// + /// The that controls when compaction proceeds. + /// + /// + /// The minimum number of most-recent non-system message groups to preserve. + /// This is a hard floor — compaction will not summarize groups beyond this limit, + /// regardless of the target condition. Defaults to 4, preserving the current and recent exchanges. + /// + /// + /// 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 minimumPreserved = 4, + string? summarizationPrompt = null, + CompactionTrigger? target = null) + : base(trigger, target) + { + this.ChatClient = Throw.IfNull(chatClient); + this.MinimumPreserved = minimumPreserved; + this.SummarizationPrompt = summarizationPrompt ?? DefaultSummarizationPrompt; + } + + /// + /// Gets the chat client used for generating summaries. + /// + public IChatClient ChatClient { get; } + + /// + /// Gets the minimum number of most-recent non-system groups that are always preserved. + /// This is a hard floor that compaction cannot exceed, regardless of the target condition. + /// + public int MinimumPreserved { get; } + + /// + /// Gets the prompt used when requesting summaries from the chat client. + /// + public string SummarizationPrompt { get; } + + /// + protected override async Task ApplyCompactionAsync(MessageIndex index, CancellationToken cancellationToken) + { + // Count non-system, non-excluded groups to determine which are protected + int nonSystemIncludedCount = 0; + for (int i = 0; i < index.Groups.Count; i++) + { + MessageGroup group = index.Groups[i]; + if (!group.IsExcluded && group.Kind != MessageGroupKind.System) + { + nonSystemIncludedCount++; + } + } + + int protectedFromEnd = Math.Min(this.MinimumPreserved, nonSystemIncludedCount); + int maxSummarizable = nonSystemIncludedCount - protectedFromEnd; + + if (maxSummarizable <= 0) + { + return false; + } + + // 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 < maxSummarizable; i++) + { + MessageGroup group = index.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 {nameof(SummarizationCompactionStrategy)}"; + summarized++; + + // Stop marking when target condition is met + if (this.Target(index)) + { + break; + } + } + + // 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."), + ], + cancellationToken: cancellationToken).ConfigureAwait(false); + + 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]\n{summaryText}"); + (summaryMessage.AdditionalProperties ??= [])[MessageGroup.SummaryPropertyKey] = true; + + index.InsertGroup(insertIndex, 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..91737394ab --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/ToolResultCompactionStrategy.cs @@ -0,0 +1,153 @@ +// Copyright (c) Microsoft. All rights reserved. + +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; + +/// +/// 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]. +/// +/// +/// 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 +/// is used. +/// +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public sealed class ToolResultCompactionStrategy : CompactionStrategy +{ + /// + /// The default minimum number of most-recent non-system groups to preserve. + /// + public const int DefaultMinimumPreserved = 2; + + /// + /// Initializes a new instance of the class. + /// + /// + /// The that controls when compaction proceeds. + /// + /// + /// The minimum number of most-recent non-system message groups to preserve. + /// This is a hard floor — compaction will not collapse groups beyond this limit, + /// regardless of the target condition. + /// 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 minimumPreserved = DefaultMinimumPreserved, CompactionTrigger? target = null) + : base(trigger, target) + { + this.MinimumPreserved = minimumPreserved; + } + + /// + /// Gets the minimum number of most-recent non-system groups that are always preserved. + /// This is a hard floor that compaction cannot exceed, regardless of the target condition. + /// + public int MinimumPreserved { get; } + + /// + 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.MinimumPreserved); + HashSet protectedGroupIndices = []; + for (int i = protectedStart; i < nonSystemIncludedIndices.Count; i++) + { + protectedGroupIndices.Add(nonSystemIncludedIndices[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)) + { + 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 = []; + 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(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 new file mode 100644 index 0000000000..46960561fc --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/TruncationCompactionStrategy.cs @@ -0,0 +1,109 @@ +// 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; + +/// +/// A compaction strategy that removes the oldest non-system message groups, +/// keeping at least most-recent groups intact. +/// +/// +/// +/// 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. +/// +/// +/// 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. +/// +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public sealed class TruncationCompactionStrategy : CompactionStrategy +{ + /// + /// The default minimum number of most-recent non-system groups to preserve. + /// + public const int DefaultMinimumPreserved = 32; + + /// + /// Initializes a new instance of the class. + /// + /// + /// The that controls when compaction proceeds. + /// + /// + /// The minimum number of most-recent non-system message groups to preserve. + /// This is a hard floor — compaction will not remove groups beyond this limit, + /// regardless of the target condition. + /// + /// + /// An optional target condition that controls when compaction stops. When , + /// defaults to the inverse of the — compaction stops as soon as the trigger would no longer fire. + /// + public TruncationCompactionStrategy(CompactionTrigger trigger, int minimumPreserved = DefaultMinimumPreserved, CompactionTrigger? target = null) + : base(trigger, target) + { + this.MinimumPreserved = minimumPreserved; + } + + /// + /// Gets the minimum number of most-recent non-system message groups that are always preserved. + /// This is a hard floor that compaction cannot exceed, regardless of the target condition. + /// + public int MinimumPreserved { get; } + + /// + protected override Task ApplyCompactionAsync(MessageIndex index, CancellationToken cancellationToken) + { + // Count removable (non-system, non-excluded) groups + int removableCount = 0; + for (int i = 0; i < index.Groups.Count; i++) + { + MessageGroup group = index.Groups[i]; + if (!group.IsExcluded && group.Kind != MessageGroupKind.System) + { + removableCount++; + } + } + + int maxRemovable = removableCount - this.MinimumPreserved; + if (maxRemovable <= 0) + { + return Task.FromResult(false); + } + + // 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++) + { + MessageGroup group = index.Groups[i]; + if (group.IsExcluded || group.Kind == MessageGroupKind.System) + { + continue; + } + + group.IsExcluded = true; + 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/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.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); + } + } +} 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..ce3bf167e5 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactingChatClientTests.cs @@ -0,0 +1,399 @@ +// 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/CompactionStrategyTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionStrategyTests.cs new file mode 100644 index 0000000000..8aa1f30637 --- /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; + bool CustomTarget(MessageIndex _) + { + 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 new file mode 100644 index 0000000000..be3a874459 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionTriggersTests.cs @@ -0,0 +1,180 @@ +// 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 TokensExceedReturnsTrueWhenAboveThreshold() + { + // Arrange — use a long message to guarantee tokens > 0 + CompactionTrigger trigger = CompactionTriggers.TokensExceed(0); + MessageIndex index = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello world")]); + + // Act & Assert + Assert.True(trigger(index)); + } + + [Fact] + public void TokensExceedReturnsFalseWhenBelowThreshold() + { + CompactionTrigger trigger = CompactionTriggers.TokensExceed(999_999); + MessageIndex index = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hi")]); + + Assert.False(trigger(index)); + } + + [Fact] + public void MessagesExceedReturnsExpectedResult() + { + 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 TurnsExceedReturnsExpectedResult() + { + 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 GroupsExceedReturnsExpectedResult() + { + 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 HasToolCallsReturnsTrueWhenToolCallGroupExists() + { + 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 HasToolCallsReturnsFalseWhenNoToolCallGroup() + { + 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 AllRequiresAllConditions() + { + 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 AnyRequiresAtLeastOneCondition() + { + 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 AllEmptyTriggersReturnsTrue() + { + CompactionTrigger trigger = CompactionTriggers.All(); + MessageIndex index = MessageIndex.Create([new ChatMessage(ChatRole.User, "A")]); + Assert.True(trigger(index)); + } + + [Fact] + public void AnyEmptyTriggersReturnsFalse() + { + CompactionTrigger trigger = CompactionTriggers.Any(); + 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 new file mode 100644 index 0000000000..395e6ff2bf --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/MessageIndexTests.cs @@ -0,0 +1,912 @@ +// 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; + +/// +/// Contains tests for the class. +/// +public class MessageIndexTests +{ + [Fact] + public void CreateEmptyListReturnsEmptyGroups() + { + // Arrange + List messages = []; + + // Act + MessageIndex groups = MessageIndex.Create(messages); + + // Assert + Assert.Empty(groups.Groups); + } + + [Fact] + public void CreateSystemMessageCreatesSystemGroup() + { + // Arrange + List messages = + [ + new ChatMessage(ChatRole.System, "You are helpful."), + ]; + + // Act + MessageIndex groups = MessageIndex.Create(messages); + + // Assert + Assert.Single(groups.Groups); + Assert.Equal(MessageGroupKind.System, groups.Groups[0].Kind); + Assert.Single(groups.Groups[0].Messages); + } + + [Fact] + public void CreateUserMessageCreatesUserGroup() + { + // Arrange + List messages = + [ + new ChatMessage(ChatRole.User, "Hello"), + ]; + + // Act + MessageIndex groups = MessageIndex.Create(messages); + + // Assert + Assert.Single(groups.Groups); + Assert.Equal(MessageGroupKind.User, groups.Groups[0].Kind); + } + + [Fact] + public void CreateAssistantTextMessageCreatesAssistantTextGroup() + { + // Arrange + List messages = + [ + new ChatMessage(ChatRole.Assistant, "Hi there!"), + ]; + + // Act + MessageIndex groups = MessageIndex.Create(messages); + + // Assert + Assert.Single(groups.Groups); + Assert.Equal(MessageGroupKind.AssistantText, groups.Groups[0].Kind); + } + + [Fact] + public void CreateToolCallWithResultsCreatesAtomicToolCallGroup() + { + // 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 + 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 CreateMixedConversationGroupsCorrectly() + { + // 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 + MessageIndex groups = MessageIndex.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 CreateMultipleToolResultsGroupsAllWithAssistant() + { + // 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 + MessageIndex groups = MessageIndex.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 GetIncludedMessagesExcludesMarkedGroups() + { + // Arrange + ChatMessage msg1 = new(ChatRole.User, "First"); + ChatMessage msg2 = new(ChatRole.Assistant, "Response"); + ChatMessage msg3 = new(ChatRole.User, "Second"); + + MessageIndex groups = MessageIndex.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 GetAllMessagesIncludesExcludedGroups() + { + // Arrange + ChatMessage msg1 = new(ChatRole.User, "First"); + ChatMessage msg2 = new(ChatRole.Assistant, "Response"); + + MessageIndex groups = MessageIndex.Create([msg1, msg2]); + groups.Groups[0].IsExcluded = true; + + // Act + List all = [.. groups.GetAllMessages()]; + + // Assert + Assert.Equal(2, all.Count); + } + + [Fact] + public void IncludedGroupCountReflectsExclusions() + { + // Arrange + MessageIndex groups = MessageIndex.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 CreateSummaryMessageCreatesSummaryGroup() + { + // Arrange + ChatMessage summaryMessage = new(ChatRole.Assistant, "[Summary of earlier conversation]: key facts..."); + (summaryMessage.AdditionalProperties ??= [])[MessageGroup.SummaryPropertyKey] = true; + + List messages = [summaryMessage]; + + // Act + MessageIndex groups = MessageIndex.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 CreateSummaryAmongOtherMessagesGroupsCorrectly() + { + // 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 + MessageIndex groups = MessageIndex.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 MessageGroupStoresPassedCounts() + { + // 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 MessageGroupMessagesAreImmutable() + { + // 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 CreateComputesByteCountUtf8() + { + // Arrange — "Hello" is 5 UTF-8 bytes + MessageIndex groups = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]); + + // Assert + Assert.Equal(5, groups.Groups[0].ByteCount); + } + + [Fact] + 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é")]); + + // Assert + Assert.Equal(5, groups.Groups[0].ByteCount); + } + + [Fact] + 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")]); + ChatMessage toolResult = new(ChatRole.Tool, "OK"); + MessageIndex groups = MessageIndex.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 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!")]); + + // Assert + Assert.Equal(22, groups.Groups[0].ByteCount); + Assert.Equal(22 / 4, groups.Groups[0].TokenCount); + } + + [Fact] + public void CreateNullTextHasZeroCounts() + { + // 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); + MessageIndex groups = MessageIndex.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 TotalAggregatesSumAllGroups() + { + // Arrange + MessageIndex groups = MessageIndex.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 IncludedAggregatesExcludeMarkedGroups() + { + // Arrange + MessageIndex groups = MessageIndex.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 ToolCallGroupAggregatesAcrossMessages() + { + // 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"); + + MessageIndex groups = MessageIndex.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 CreateAssignsTurnIndicesSingleTurn() + { + // Arrange — System (no turn), User + Assistant = turn 1 + MessageIndex groups = MessageIndex.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 CreateAssignsTurnIndicesMultiTurn() + { + // Arrange — 3 user turns + MessageIndex groups = MessageIndex.Create( + [ + new ChatMessage(ChatRole.System, "System prompt."), + new ChatMessage(ChatRole.User, "Q1"), + new ChatMessage(ChatRole.Assistant, "A1"), + new ChatMessage(ChatRole.User, "Q2"), + new ChatMessage(ChatRole.Assistant, "A2"), + new ChatMessage(ChatRole.User, "Q3"), + ]); + + // 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 CreateTurnSpansToolCallGroups() + { + // Arrange — turn 1 includes User, ToolCall, AssistantText + ChatMessage assistantToolCall = 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?"), + 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 GetTurnGroupsReturnsGroupsForSpecificTurn() + { + // Arrange + 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 + 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 IncludedTurnCountReflectsExclusions() + { + // Arrange — 2 turns, exclude all groups in turn 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"), + ]); + + 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 TotalTurnCountZeroWhenNoUserMessages() + { + // Arrange — only system messages + MessageIndex groups = MessageIndex.Create( + [ + new ChatMessage(ChatRole.System, "System."), + ]); + + // Assert + Assert.Equal(0, groups.TotalTurnCount); + Assert.Equal(0, groups.IncludedTurnCount); + } + + [Fact] + public void IncludedTurnCountPartialExclusionStillCountsTurn() + { + // Arrange — turn 1 has 2 groups, only one excluded + MessageIndex groups = MessageIndex.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); + } + + [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) + } + + [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); + } + + [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. + /// + 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) + { + // Simple word-based encoding + string input = text ?? textSpan.ToString(); + if (string.IsNullOrWhiteSpace(input)) + { + return new 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 EncodedToken(i, words[i], new System.Range(offset, offset + words[i].Length))); + offset += words[i].Length + 1; + } + + return new EncodeResults + { + Tokens = tokens, + CharsConsumed = input.Length, + NormalizedText = null, + }; + } + + public override OperationStatus Decode(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/PipelineCompactionStrategyTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/PipelineCompactionStrategyTests.cs new file mode 100644 index 0000000000..0bb7b022dc --- /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 CompactAsyncExecutesAllStrategiesInOrderAsync() + { + // 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 CompactAsyncReturnsFalseWhenNoStrategyCompactsAsync() + { + // 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 CompactAsyncReturnsTrueWhenAnyStrategyCompactsAsync() + { + // 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 CompactAsyncContinuesAfterFirstCompactionAsync() + { + // 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 CompactAsyncComposesStrategiesEndToEndAsync() + { + // 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 CompactAsyncEmptyPipelineReturnsFalseAsync() + { + // 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..709a44e0a4 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/SlidingWindowCompactionStrategyTests.cs @@ -0,0 +1,250 @@ +// 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 CompactAsyncBelowMaxTurnsReturnsFalseAsync() + { + // Arrange — trigger requires > 3 turns, conversation has 2 + SlidingWindowCompactionStrategy strategy = new(CompactionTriggers.TurnsExceed(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 CompactAsyncExceedsMaxTurnsExcludesOldestTurnsAsync() + { + // Arrange — trigger on > 2 turns, conversation has 3 + SlidingWindowCompactionStrategy strategy = new(CompactionTriggers.TurnsExceed(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 CompactAsyncPreservesSystemMessagesAsync() + { + // Arrange — trigger on > 1 turn + SlidingWindowCompactionStrategy strategy = new(CompactionTriggers.TurnsExceed(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 CompactAsyncPreservesToolCallGroupsInKeptTurnsAsync() + { + // Arrange — trigger on > 1 turn + SlidingWindowCompactionStrategy strategy = new(CompactionTriggers.TurnsExceed(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 CompactAsyncTriggerNotMetReturnsFalseAsync() + { + // Arrange — trigger requires > 99 turns + SlidingWindowCompactionStrategy strategy = new(CompactionTriggers.TurnsExceed(99)); + + MessageIndex groups = MessageIndex.Create( + [ + new ChatMessage(ChatRole.User, "Q1"), + new ChatMessage(ChatRole.User, "Q2"), + new ChatMessage(ChatRole.User, "Q3"), + ]); + + // Act + bool result = await strategy.CompactAsync(groups); + + // Assert + Assert.False(result); + } + + [Fact] + public async Task CompactAsyncIncludedMessagesContainOnlyKeptTurnsAsync() + { + // Arrange — trigger on > 1 turn + SlidingWindowCompactionStrategy strategy = new(CompactionTriggers.TurnsExceed(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); + } + + [Fact] + public async Task CompactAsyncCustomTargetStopsExcludingEarlyAsync() + { + // Arrange — trigger on > 1 turn, custom target stops after removing 1 turn + int removeCount = 0; + bool TargetAfterOne(MessageIndex _) => ++removeCount >= 1; + + SlidingWindowCompactionStrategy strategy = new( + CompactionTriggers.TurnsExceed(1), + minimumPreserved: 0, + 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) + } + + [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 + } + + [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 new file mode 100644 index 0000000000..0bf225ae34 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/SummarizationCompactionStrategyTests.cs @@ -0,0 +1,410 @@ +// 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 +{ + /// + /// 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), + minimumPreserved: 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."), + CompactionTriggers.Always, + minimumPreserved: 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(), + CompactionTriggers.Always, + minimumPreserved: 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."), + CompactionTriggers.Always, + minimumPreserved: 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(" "), + CompactionTriggers.Always, + minimumPreserved: 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(), + CompactionTriggers.Always, + minimumPreserved: 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, + CompactionTriggers.Always, + minimumPreserved: 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(), + CompactionTriggers.Always, + minimumPreserved: 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; + bool TargetAfterOne(MessageIndex _) => ++exclusionCount >= 1; + + SummarizationCompactionStrategy strategy = new( + CreateMockChatClient("Partial summary."), + CompactionTriggers.Always, + minimumPreserved: 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."), + CompactionTriggers.Always, + minimumPreserved: 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); + } + + [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 new file mode 100644 index 0000000000..3d4a34ac54 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/ToolResultCompactionStrategyTests.cs @@ -0,0 +1,262 @@ +// 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 ToolResultCompactionStrategyTests +{ + [Fact] + public async Task CompactAsyncTriggerNotMetReturnsFalseAsync() + { + // 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 CompactAsyncCollapsesOldToolGroupsAsync() + { + // Arrange — always trigger + ToolResultCompactionStrategy strategy = new( + trigger: _ => true, + minimumPreserved: 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 CompactAsyncPreservesRecentToolGroupsAsync() + { + // Arrange — protect 2 recent non-system groups (the tool group + Q2) + ToolResultCompactionStrategy strategy = new( + trigger: _ => true, + minimumPreserved: 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 CompactAsyncPreservesSystemMessagesAsync() + { + // Arrange + ToolResultCompactionStrategy strategy = new( + trigger: _ => true, + minimumPreserved: 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 CompactAsyncExtractsMultipleToolNamesAsync() + { + // Arrange — assistant calls two tools + ToolResultCompactionStrategy strategy = new( + trigger: _ => true, + minimumPreserved: 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 CompactAsyncNoToolGroupsReturnsFalseAsync() + { + // Arrange — trigger fires but no tool groups to collapse + ToolResultCompactionStrategy strategy = new( + trigger: _ => true, + minimumPreserved: 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 CompactAsyncCompoundTriggerRequiresTokensAndToolCallsAsync() + { + // Arrange — compound: tokens > 0 AND has tool calls + ToolResultCompactionStrategy strategy = new( + CompactionTriggers.All( + CompactionTriggers.TokensExceed(0), + CompactionTriggers.HasToolCalls()), + minimumPreserved: 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); + } + + [Fact] + public async Task CompactAsyncTargetStopsCollapsingEarlyAsync() + { + // Arrange — 2 tool groups, target met after first collapse + int collapseCount = 0; + bool TargetAfterOne(MessageIndex _) => ++collapseCount >= 1; + + ToolResultCompactionStrategy strategy = new( + trigger: _ => true, + minimumPreserved: 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); + } + + [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 new file mode 100644 index 0000000000..2783fa029c --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/TruncationCompactionStrategyTests.cs @@ -0,0 +1,328 @@ +// Copyright (c) Microsoft. All rights reserved. + +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 TruncationCompactionStrategyTests +{ + [Fact] + public async Task CompactAsyncAlwaysTriggerCompactsToPreserveRecentAsync() + { + // Arrange — always-trigger means always compact + TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreserved: 1); + MessageIndex groups = MessageIndex.Create( + [ + 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.True(result); + Assert.Equal(1, groups.Groups.Count(g => !g.IsExcluded)); + } + + [Fact] + public async Task CompactAsyncTriggerNotMetReturnsFalseAsync() + { + // Arrange — trigger requires > 1000 tokens, conversation is tiny + TruncationCompactionStrategy strategy = new( + minimumPreserved: 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 CompactAsyncTriggerMetExcludesOldestGroupsAsync() + { + // Arrange — trigger on groups > 2 + TruncationCompactionStrategy strategy = new( + minimumPreserved: 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 — incremental: excludes until GroupsExceed(2) is no longer met → 2 groups remain + Assert.True(result); + Assert.Equal(2, groups.IncludedGroupCount); + // Oldest 2 excluded, newest 2 kept + 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 CompactAsyncPreservesSystemMessagesAsync() + { + // Arrange + TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreserved: 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); + + // 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 excluded + Assert.True(groups.Groups[1].IsExcluded); + Assert.True(groups.Groups[2].IsExcluded); + // Most recent kept + Assert.False(groups.Groups[3].IsExcluded); + } + + [Fact] + public async Task CompactAsyncPreservesToolCallGroupAtomicityAsync() + { + // Arrange + TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreserved: 1); + + ChatMessage assistantToolCall = new(ChatRole.Assistant, [new FunctionCallContent("call1", "get_weather")]); + ChatMessage toolResult = new(ChatRole.Tool, "Sunny"); + ChatMessage finalResponse = new(ChatRole.User, "Thanks!"); + + MessageIndex groups = MessageIndex.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 CompactAsyncSetsExcludeReasonAsync() + { + // Arrange + TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreserved: 1); + MessageIndex groups = MessageIndex.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 CompactAsyncSkipsAlreadyExcludedGroupsAsync() + { + // Arrange + TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreserved: 1); + MessageIndex groups = MessageIndex.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 + } + + [Fact] + public async Task CompactAsyncMinimumPreservedKeepsMultipleAsync() + { + // Arrange — keep 2 most recent + TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreserved: 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 CompactAsyncNothingToRemoveReturnsFalseAsync() + { + // Arrange — preserve 5 but only 2 groups + TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreserved: 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); + } + + [Fact] + public async Task CompactAsyncCustomTargetStopsEarlyAsync() + { + // Arrange — always trigger, custom target stops after 1 exclusion + int targetChecks = 0; + bool TargetAfterOne(MessageIndex _) => ++targetChecks >= 1; + + TruncationCompactionStrategy strategy = new( + CompactionTriggers.Always, + minimumPreserved: 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), + minimumPreserved: 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); + } + + [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 + } +} 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 @@ +