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..d51fc75621 --- /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. +const int MaxTokens = 512; +const int MaxTurns = 4; + +ChatHistoryCompactionPipeline compactionPipeline = + new(// 1. Gentle: collapse old tool-call groups into short summaries like "[Tool calls: LookupPrice]" + new ToolResultCompactionStrategy(MaxTokens, preserveRecentGroups: 2), + + // 2. Moderate: use an LLM to summarize older conversation spans into a concise message + new SummarizationCompactionStrategy(summarizerChatClient, MaxTokens, preserveRecentGroups: 2), + + // 3. Aggressive: keep only the last N user turns and their responses + new SlidingWindowCompactionStrategy(MaxTurns), + + // 4. Emergency: drop oldest groups until under the token budget + new TruncationCompactionStrategy(MaxTokens, preserveRecentGroups: 1)); + +// TODO: PRECONFIGURED PIPELINE +////Create( +//// Approach.Balanced, +//// Size.Compact, +//// summarizerChatClient); + +// 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)], + }, + ChatHistoryProvider = new InMemoryChatHistoryProvider(new() { ChatReducer = compactionPipeline }), + }); + +AgentSession session = await agent.CreateSessionAsync(); + +// Helper to print chat history size +void PrintChatHistory() +{ + if (session.TryGetInMemoryChatHistory(out var history)) + { + Console.WriteLine($" [Chat history: {history.Count} messages]\n"); + } +} + +// Run a multi-turn conversation with tool calls to exercise the pipeline. +string[] prompts = +[ + "What's the price of a laptop?", + "How about a keyboard?", + "And a mouse?", + "Which product is the cheapest?", + "Can you compare the laptop and the keyboard for me?", + "What was the first product I asked about?", + "Thank you!", +]; + +foreach (string prompt in prompts) +{ + Console.WriteLine($"User: {prompt}"); + Console.WriteLine($"Agent: {await agent.RunAsync(prompt, session)}"); + PrintChatHistory(); +} diff --git a/dotnet/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/Compaction/ChatHistoryCompactionPipeline.Factory.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ChatHistoryCompactionPipeline.Factory.cs new file mode 100644 index 0000000000..2e9cafd5a3 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ChatHistoryCompactionPipeline.Factory.cs @@ -0,0 +1,102 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Compaction; + +public partial class ChatHistoryCompactionPipeline +{ + /// + /// %%% COMMENT + /// + public enum Size + { + /// + /// %%% COMMENT + /// + Compact, + /// + /// %%% COMMENT + /// + Adequate, + /// + /// %%% COMMENT + /// + Accomodating, + } + + /// + /// %%% COMMENT + /// + public enum Approach + { + /// + /// %%% COMMENT + /// + Aggressive, + /// + /// %%% COMMENT + /// + Balanced, + /// + /// %%% COMMENT + /// + Gentle, + } + + /// + /// %%% COMMENT + /// + /// + /// + /// + /// + /// + public static ChatHistoryCompactionPipeline Create(Approach approach, Size size, IChatClient chatClient) => + approach switch + { + Approach.Aggressive => CreateAgressive(size, chatClient), + Approach.Balanced => CreateBalanced(size), + Approach.Gentle => CreateGentle(size), + _ => throw new NotImplementedException(), // %%% EXCEPTION + }; + + private static ChatHistoryCompactionPipeline CreateAgressive(Size size, IChatClient chatClient) => + new(// 1. Gentle: collapse old tool-call groups into short summaries like "[Tool calls: LookupPrice]" + new ToolResultCompactionStrategy(MaxTokens(size), preserveRecentGroups: 2), + // 2. Moderate: use an LLM to summarize older conversation spans into a concise message + new SummarizationCompactionStrategy(chatClient, MaxTokens(size), preserveRecentGroups: 2), + // 3. Aggressive: keep only the last N user turns and their responses + new SlidingWindowCompactionStrategy(MaxTurns(size)), + // 4. Emergency: drop oldest groups until under the token budget + new TruncationCompactionStrategy(MaxTokens(size), preserveRecentGroups: 1)); + + private static ChatHistoryCompactionPipeline CreateBalanced(Size size) => + new(// 1. Gentle: collapse old tool-call groups into short summaries like "[Tool calls: LookupPrice]" + new ToolResultCompactionStrategy(MaxTokens(size), preserveRecentGroups: 2), + // 2. Aggressive: keep only the last N user turns and their responses + new SlidingWindowCompactionStrategy(MaxTurns(size))); + + private static ChatHistoryCompactionPipeline CreateGentle(Size size) => + new(// 1. Gentle: collapse old tool-call groups into short summaries like "[Tool calls: LookupPrice]" + new ToolResultCompactionStrategy(MaxTokens(size), preserveRecentGroups: 2)); + + private static int MaxTokens(Size size) => + size switch + { + Size.Compact => 500, + Size.Adequate => 1000, + Size.Accomodating => 2000, + _ => throw new NotImplementedException(), // %%% EXCEPTION + }; + + private static int MaxTurns(Size size) => + size switch + { + Size.Compact => 10, + Size.Adequate => 50, + Size.Accomodating => 100, + _ => throw new NotImplementedException(), // %%% EXCEPTION + }; +} diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ChatHistoryCompactionPipeline.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ChatHistoryCompactionPipeline.cs new file mode 100644 index 0000000000..9dc4c4e507 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ChatHistoryCompactionPipeline.cs @@ -0,0 +1,114 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Compaction; + +/// +/// Executes a chain of instances in order +/// against a mutable message list. +/// +/// +/// +/// Each strategy's trigger is evaluated against the metrics as they stand after prior strategies, +/// so earlier strategies can bring the conversation within thresholds that cause later strategies to skip. +/// +/// +/// The pipeline is fully standalone — it can be used without any agent, session, or context provider. +/// It also implements so it can be used directly anywhere a reducer is +/// accepted (e.g., ). +/// +/// +public partial class ChatHistoryCompactionPipeline : IChatReducer +{ + private readonly ChatHistoryCompactionStrategy[] _strategies; + private readonly IChatHistoryMetricsCalculator _metricsCalculator; + + /// + /// Initializes a new instance of the class. + /// + /// The ordered list of compaction strategies to execute. + /// + /// By default, is used. + /// + public ChatHistoryCompactionPipeline( + params IEnumerable strategies) + : this(metricsCalculator: null, strategies) { } + + /// + /// Initializes a new instance of the class. + /// + /// + /// An optional metrics calculator. When , a + /// is used. + /// + /// The ordered list of compaction strategies to execute. + public ChatHistoryCompactionPipeline( + IChatHistoryMetricsCalculator? metricsCalculator, + params IEnumerable strategies) + { + this._strategies = [.. Throw.IfNull(strategies)]; + this._metricsCalculator = metricsCalculator ?? DefaultChatHistoryMetricsCalculator.Instance; + } + + /// + /// Reduces the given messages by running all strategies in sequence. + /// + /// The messages to reduce. + /// The to monitor for cancellation requests. + /// The reduced set of messages. + public virtual async Task> ReduceAsync( + IEnumerable messages, + CancellationToken cancellationToken = default) + { + List messageBuffer = messages is List messageList ? messageList : [.. messages]; + await this.CompactAsync(messageBuffer, cancellationToken).ConfigureAwait(false); + return messageBuffer; + } + + /// + /// Run all strategies in sequence against the given messages. + /// + /// The mutable message list to compact. + /// The to monitor for cancellation requests. + /// A with aggregate and per-strategy metrics. + public async ValueTask CompactAsync( + List messages, + CancellationToken cancellationToken = default) + { + Throw.IfNull(messages); + + ChatHistoryMetric overallBefore = this._metricsCalculator.Calculate(messages); + + Debug.WriteLine($"COMPACTION: BEGIN x{overallBefore.MessageCount}/#{overallBefore.UserTurnCount} ({overallBefore.TokenCount} tokens)"); + + List compactionResults = new(this._strategies.Length); + + Stopwatch timer = new(); + TimeSpan startTime = TimeSpan.Zero; + ChatHistoryMetric overallAfter = overallBefore; + ChatHistoryMetric currentBefore = overallBefore; + foreach (ChatHistoryCompactionStrategy strategy in this._strategies) + { + // %%% VERBOSE - Debug.WriteLine($"COMPACTION: {strategy.Name} START"); + timer.Start(); + ChatHistoryCompactionStrategy.s_currentMetrics.Value = currentBefore; + CompactionResult strategyResult = await strategy.CompactAsync(messages, this._metricsCalculator, cancellationToken).ConfigureAwait(false); + timer.Stop(); + TimeSpan elapsedTime = timer.Elapsed - startTime; + // %%% VERBOSE - Debug.WriteLine($"COMPACTION: {strategy.Name} FINISH [{elapsedTime}]"); + compactionResults.Add(strategyResult); + overallAfter = currentBefore = strategyResult.After; + } + + Debug.WriteLineIf(overallBefore.TokenCount != overallAfter.TokenCount, $"COMPACTION: TOTAL [{timer.Elapsed}] {overallBefore.TokenCount} => {overallAfter.TokenCount} tokens"); + + return new(overallBefore, overallAfter, compactionResults); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ChatHistoryCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ChatHistoryCompactionStrategy.cs new file mode 100644 index 0000000000..5d78bfe6e5 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ChatHistoryCompactionStrategy.cs @@ -0,0 +1,120 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Compaction; + +/// +/// A named compaction strategy with an optional conditional trigger that delegates +/// actual message reduction to an . +/// +/// +/// +/// Each strategy wraps an that performs the actual compaction, +/// while the strategy adds: +/// +/// A conditional trigger via that decides whether compaction runs. +/// Before/after reporting via . +/// +/// +/// +/// For simple cases, construct a directly with any +/// . For custom trigger logic, subclass and override . +/// +/// +/// Reducers must preserve atomic message groups: an assistant message containing +/// tool calls and its corresponding tool result messages must be kept or removed together. +/// Use to identify these groups when authoring custom reducers. +/// +/// +public abstract class ChatHistoryCompactionStrategy +{ + internal static readonly AsyncLocal s_currentMetrics = new(); + + /// + /// Initializes a new instance of the class. + /// + /// The that performs the actual message compaction. + protected ChatHistoryCompactionStrategy(IChatReducer reducer) + { + this.Reducer = Throw.IfNull(reducer); + } + + /// + /// Exposes the current for the executing strategy, allowing to make informed decisions. + /// + protected static ChatHistoryMetric CurrentMetrics => s_currentMetrics.Value ?? throw new InvalidOperationException($"No active {nameof(ChatHistoryCompactionStrategy)}."); + + /// + /// Gets the that performs the actual message compaction. + /// + public IChatReducer Reducer { get; } + + /// + /// Gets the display name of this strategy, used for logging and diagnostics. + /// + /// + /// The default implementation returns the type name of the underlying . + /// + public virtual string Name => this.Reducer.GetType().Name; + + /// + /// Evaluates whether this strategy should execute given the current conversation metrics. + /// + /// The current conversation metrics. + /// + /// to proceed with compaction; to skip. + /// + protected abstract bool ShouldCompact(ChatHistoryMetric metrics); + + /// + /// Execute this strategy: check the trigger, delegate to the , and report metrics. + /// + /// The mutable message list to compact. + /// The calculator to use for metric snapshots. + /// The to monitor for cancellation requests. + /// A reporting the outcome. + internal async ValueTask CompactAsync( + List history, + IChatHistoryMetricsCalculator metricsCalculator, + CancellationToken cancellationToken = default) + { + Throw.IfNull(metricsCalculator); + Throw.IfNull(history); + + ChatHistoryMetric beforeMetrics = CurrentMetrics; + if (!this.ShouldCompact(beforeMetrics)) + { + // %%% VERBOSE - Debug.WriteLine($"COMPACTION: {this.Name} - Skipped"); + return CompactionResult.Skipped(this.Name, beforeMetrics); + } + + Debug.WriteLine($"COMPACTION: {this.Name} - Reducing"); + + IEnumerable reducerResult = await this.Reducer.ReduceAsync(history, cancellationToken).ConfigureAwait(false); + + // Ensure we have a concrete collection to avoid multiple enumerations of the reducer result, which could be costly if it's an iterator. + ChatMessage[] reducedCopy = [.. reducerResult]; + + bool modified = reducedCopy.Length != history.Count; + if (modified) + { + history.Clear(); + history.AddRange(reducedCopy); + } + + ChatHistoryMetric afterMetrics = modified + ? metricsCalculator.Calculate(reducedCopy) + : beforeMetrics; + + Debug.WriteLine($"COMPACTION: {this.Name} - Tokens {beforeMetrics.TokenCount} => {afterMetrics.TokenCount}"); + + return new(this.Name, applied: modified, beforeMetrics, afterMetrics); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ChatHistoryMetric.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ChatHistoryMetric.cs new file mode 100644 index 0000000000..f2d9694dfe --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ChatHistoryMetric.cs @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; + +namespace Microsoft.Agents.AI.Compaction; + +/// +/// Immutable snapshot of conversation metrics used for compaction trigger evaluation and reporting. +/// +public sealed class ChatHistoryMetric +{ + /// + /// Gets the estimated token count across all messages. + /// + public int TokenCount { get; init; } + + /// + /// Gets the total serialized byte count of all messages. + /// + public long ByteCount { get; init; } + +#pragma warning disable IDE0001 // Simplify Names + /// + /// Gets the total number of objects. + /// +#pragma warning restore IDE0001 // Simplify Names + public int MessageCount { get; init; } + + /// + /// Gets the number of tool/function call content items across all messages. + /// + public int ToolCallCount { get; init; } + + /// + /// Gets the number of user turns. A user turn is a user message together with the full + /// set of agent responses (including tool calls and results) before the next user input. + /// + public int UserTurnCount { get; init; } + + /// + /// Gets the atomic message group index for the analyzed messages. + /// Each group represents a contiguous range of messages that must be kept or removed together. + /// + public IReadOnlyList Groups { get; init; } = []; +} diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ChatMessageGroup.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ChatMessageGroup.cs new file mode 100644 index 0000000000..e9bc36bc19 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ChatMessageGroup.cs @@ -0,0 +1,64 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; + +namespace Microsoft.Agents.AI.Compaction; + +/// +/// Represents a contiguous range of messages in a conversation that form an atomic group. +/// Atomic groups must be kept or removed together to maintain API correctness. +/// +/// +/// For example, an assistant message containing tool calls and the subsequent tool result messages +/// form an atomic group — removing one without the other causes API errors. +/// +public readonly struct ChatMessageGroup : IEquatable +{ + /// + /// Initializes a new instance of the struct. + /// + /// The zero-based index of the first message in this group. + /// The number of messages in this group. + /// The kind of this message group. + public ChatMessageGroup(int startIndex, int count, ChatMessageGroupKind kind) + { + this.StartIndex = startIndex; + this.Count = count; + this.Kind = kind; + } + + /// + /// Gets the zero-based index of the first message in this group within the original message list. + /// + public int StartIndex { get; } + + /// + /// Gets the number of messages in this group. + /// + public int Count { get; } + + /// + /// Gets the kind of this message group. + /// + public ChatMessageGroupKind Kind { get; } + + /// + public bool Equals(ChatMessageGroup other) => + this.StartIndex == other.StartIndex && + this.Count == other.Count && + this.Kind == other.Kind; + + /// + public override bool Equals(object? obj) => + obj is ChatMessageGroup other && + this.Equals(other); + + /// + public override int GetHashCode() => HashCode.Combine(this.StartIndex, this.Count, (int)this.Kind); + + /// Determines whether two instances are equal. + public static bool operator ==(ChatMessageGroup left, ChatMessageGroup right) => left.Equals(right); + + /// Determines whether two instances are not equal. + public static bool operator !=(ChatMessageGroup left, ChatMessageGroup right) => !left.Equals(right); +} diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ChatMessageGroupKind.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ChatMessageGroupKind.cs new file mode 100644 index 0000000000..8a42f88ebd --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ChatMessageGroupKind.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.Compaction; + +/// +/// Identifies the kind of an atomic message group in a conversation. +/// +public enum ChatMessageGroupKind +{ + /// A system message. + System, + + /// A user message (start of a user turn). + UserTurn, + + /// An assistant message with tool calls and their corresponding tool result messages. + AssistantToolGroup, + + /// An assistant message without tool calls. + AssistantPlain, + + /// A tool result message that is not part of a recognized group. + ToolResult, + + /// A message with an unrecognized role. + Other +} diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ChatReducerCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ChatReducerCompactionStrategy.cs new file mode 100644 index 0000000000..7305e86cd5 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ChatReducerCompactionStrategy.cs @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using Microsoft.Extensions.AI; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Compaction; + +/// +/// Represents a chat history compaction strategy that uses a condition function to determine when compaction should +/// occur. +/// +/// +/// This strategy evaluates a user-provided condition against compaction metrics to decide whether to +/// compact the chat history. It is useful for scenarios where compaction should be triggered based on custom thresholds +/// or criteria. Inherits from ChatHistoryCompactionStrategy. +/// +public class ChatReducerCompactionStrategy : ChatHistoryCompactionStrategy +{ + private readonly Func _condition; + + /// + /// Initializes a new instance of the class. + /// + public ChatReducerCompactionStrategy( + IChatReducer reducer, + Func condition) + : base(reducer) + { + this._condition = Throw.IfNull(condition); + } + + /// + protected override bool ShouldCompact(ChatHistoryMetric metrics) => this._condition(metrics); +} diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/CompactionPipelineResult.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/CompactionPipelineResult.cs new file mode 100644 index 0000000000..c416528a83 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/CompactionPipelineResult.cs @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Linq; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Compaction; + +/// +/// Reports the aggregate outcome of a execution. +/// +public sealed class CompactionPipelineResult +{ + /// + /// Initializes a new instance of the class. + /// + /// Metrics of the conversation before any strategy ran. + /// Metrics of the conversation after all strategies ran. + /// Per-strategy results in execution order. + internal CompactionPipelineResult( + ChatHistoryMetric before, + ChatHistoryMetric after, + IReadOnlyList strategyResults) + { + this.Before = Throw.IfNull(before); + this.After = Throw.IfNull(after); + this.StrategyResults = Throw.IfNull(strategyResults); + } + + /// + /// Gets the conversation metrics before any compaction strategy ran. + /// + public ChatHistoryMetric Before { get; } + + /// + /// Gets the conversation metrics after all compaction strategies ran. + /// + public ChatHistoryMetric After { get; } + + /// + /// Gets the per-strategy results in execution order. + /// + public IReadOnlyList StrategyResults { get; } + + /// + /// Gets a value indicating whether any strategy modified the message list. + /// + public bool AnyApplied => this.StrategyResults.Any(r => r.Applied); +} diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/CompactionResult.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/CompactionResult.cs new file mode 100644 index 0000000000..2c4b2ad13e --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/CompactionResult.cs @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Compaction; + +/// +/// Reports the outcome of a single execution. +/// +public sealed class CompactionResult +{ + /// + /// Initializes a new instance of the class. + /// + /// The name of the strategy that produced this result. + /// Whether the strategy modified the message list. + /// Metrics before the strategy ran. + /// Metrics after the strategy ran. + public CompactionResult(string strategyName, bool applied, ChatHistoryMetric before, ChatHistoryMetric after) + { + this.StrategyName = Throw.IfNullOrWhitespace(strategyName); + this.Applied = applied; + this.Before = Throw.IfNull(before); + this.After = Throw.IfNull(after); + } + + /// + /// Gets the name of the strategy that produced this result. + /// + public string StrategyName { get; } + + /// + /// Gets a value indicating whether the strategy modified the message list. + /// + public bool Applied { get; } + + /// + /// Gets the conversation metrics before the strategy executed. + /// + public ChatHistoryMetric Before { get; } + + /// + /// Gets the conversation metrics after the strategy executed. + /// + public ChatHistoryMetric After { get; } + + /// + /// Creates a representing a skipped strategy. + /// + /// The name of the skipped strategy. + /// The current conversation metrics. + /// A result indicating no compaction was applied. + internal static CompactionResult Skipped(string strategyName, ChatHistoryMetric metrics) + => new(strategyName, applied: false, metrics, metrics); +} diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/DefaultChatHistoryMetricsCalculator.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/DefaultChatHistoryMetricsCalculator.cs new file mode 100644 index 0000000000..2491f99591 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/DefaultChatHistoryMetricsCalculator.cs @@ -0,0 +1,161 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Linq; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Compaction; + +/// +/// Default implementation of that uses +/// JSON serialization length heuristics for token and byte estimation. +/// +/// +/// +/// Token estimation uses a configurable characters-per-token ratio (default ~4) since +/// precise tokenization requires a model-specific tokenizer. For production workloads +/// requiring accurate token counts, implement +/// with a model-appropriate tokenizer. +/// +/// +public sealed class DefaultChatHistoryMetricsCalculator : IChatHistoryMetricsCalculator +{ + /// + /// Gets the singleton instance of the chat history metrics calculator. + /// + /// + /// can be safety accessed by + /// concurrent threads. + /// + public static readonly DefaultChatHistoryMetricsCalculator Instance = new(); + + private const int DefaultCharsPerToken = 4; + private const int PerMessageOverheadTokens = 4; + + private readonly int _charsPerToken; + + /// + /// Initializes a new instance of the class. + /// + /// + /// The approximate number of characters per token used for estimation. Default is 4. + /// + public DefaultChatHistoryMetricsCalculator(int charsPerToken = DefaultCharsPerToken) + { + this._charsPerToken = charsPerToken > 0 ? charsPerToken : DefaultCharsPerToken; + } + + /// + public ChatHistoryMetric Calculate(IReadOnlyList messages) + { + if (messages is null || messages.Count == 0) + { + return new(); + } + + int totalTokens = 0; + long totalBytes = 0; + int toolCallCount = 0; + int userTurnCount = 0; + bool inUserTurn = false; + List groups = []; + int index = 0; + + while (index < messages.Count) + { + ChatMessage message = messages[index]; + + // Accumulate per-message metrics + this.AccumulateMessageMetrics(message, ref totalTokens, ref totalBytes, ref toolCallCount); + + if (message.Role == ChatRole.User) + { + if (!inUserTurn) + { + userTurnCount++; + inUserTurn = true; + } + } + else + { + inUserTurn = false; + } + + // Identify the group starting at this message + if (message.Role == ChatRole.System) + { + groups.Add(new(index, 1, ChatMessageGroupKind.System)); + index++; + } + else if (message.Role == ChatRole.User) + { + groups.Add(new(index, 1, ChatMessageGroupKind.UserTurn)); + index++; + } + else if (message.Role == ChatRole.Assistant) + { + bool hasToolCalls = message.Contents!.Any(c => c is FunctionCallContent); + + if (hasToolCalls) + { + int groupStart = index; + index++; + + while (index < messages.Count && messages[index].Role == ChatRole.Tool) + { + this.AccumulateMessageMetrics(messages[index], ref totalTokens, ref totalBytes, ref toolCallCount); + inUserTurn = false; + index++; + } + + groups.Add(new(groupStart, index - groupStart, ChatMessageGroupKind.AssistantToolGroup)); + } + else + { + groups.Add(new(index, 1, ChatMessageGroupKind.AssistantPlain)); + index++; + } + } + else if (message.Role == ChatRole.Tool) + { + groups.Add(new(index, 1, ChatMessageGroupKind.ToolResult)); + index++; + } + else + { + groups.Add(new(index, 1, ChatMessageGroupKind.Other)); + index++; + } + } + + return new() + { + TokenCount = totalTokens, + ByteCount = totalBytes, + MessageCount = messages.Count, + ToolCallCount = toolCallCount, + UserTurnCount = userTurnCount, + Groups = groups + }; + } + + private void AccumulateMessageMetrics(ChatMessage message, ref int totalTokens, ref long totalBytes, ref int toolCallCount) + { + string serialized = message.Text; + + int charCount = serialized.Length; + totalBytes += System.Text.Encoding.UTF8.GetByteCount(serialized); + totalTokens += (charCount / this._charsPerToken) + PerMessageOverheadTokens; + + if (message.Contents is not null) + { + foreach (AIContent content in message.Contents) + { + if (content is FunctionCallContent) + { + toolCallCount++; + } + } + } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/IChatHistoryMetricsCalculator.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/IChatHistoryMetricsCalculator.cs new file mode 100644 index 0000000000..3c4c124444 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/IChatHistoryMetricsCalculator.cs @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Compaction; + +// %%% TODO: Is this interface needed? Consider whether the default implementation is sufficient +// and whether custom metrics calculators are a realistic extension point. + +/// +/// Computes for a list of messages. +/// +/// +/// Token counting is model-specific. Implementations can provide precise tokenization +/// (e.g., using tiktoken or a model-specific tokenizer) or use estimation heuristics. +/// +public interface IChatHistoryMetricsCalculator +{ + /// + /// Compute metrics for the given messages. + /// + /// The messages to analyze. + /// A snapshot. + ChatHistoryMetric Calculate(IReadOnlyList messages); +} diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/SlidingWindowCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/SlidingWindowCompactionStrategy.cs new file mode 100644 index 0000000000..51b5b90a1d --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/SlidingWindowCompactionStrategy.cs @@ -0,0 +1,91 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Compaction; + +/// +/// A compaction strategy that keeps only the most recent user turns and their +/// associated response groups, removing older turns to bound conversation length. +/// +/// +/// +/// This strategy always preserves system messages. It identifies user turns in the +/// conversation and keeps the last maxTurns turns along with all response groups +/// (assistant replies, tool call groups) that follow each kept turn. +/// +/// +/// The trigger condition fires only when the number of user turns exceeds maxTurns. +/// +/// +/// This strategy is more predictable than token-based truncation for bounding conversation +/// length, since it operates on logical turn boundaries rather than estimated token counts. +/// +/// +public class SlidingWindowCompactionStrategy : ChatHistoryCompactionStrategy +{ + private readonly int _maxTurns; + + /// + /// Initializes a new instance of the class. + /// + /// + /// The maximum number of user turns to keep. Older turns and their associated responses are removed. + /// + public SlidingWindowCompactionStrategy(int maxTurns) + : base(new SlidingWindowReducer(maxTurns)) + { + this._maxTurns = maxTurns; + } + + /// + protected override bool ShouldCompact(ChatHistoryMetric metrics) => + metrics.UserTurnCount > this._maxTurns; + + /// + /// An that keeps system messages and the last N user turns + /// with all their associated response groups. + /// + private sealed class SlidingWindowReducer(int maxTurns) : IChatReducer + { + public Task> ReduceAsync( + IEnumerable messages, + CancellationToken cancellationToken = default) + { + IReadOnlyList messageList = [.. messages]; // %%% PERFORMANCE + IReadOnlyList groups = CurrentMetrics.Groups; + + // Find the group-list indices where each user turn starts + int[] turnGroupIndices = + [.. CurrentMetrics.Groups + .Select((group, index) => (group, index)) + .Where(t => t.group.Kind == ChatMessageGroupKind.UserTurn) + .Select(t => t.index)]; + + // Keep the last maxTurns user turns and everything after the first kept turn + int firstKeptTurnIndex = turnGroupIndices.Length - maxTurns; + int firstKeptGroupIndex = turnGroupIndices[firstKeptTurnIndex]; + + List result = new(messageList.Count); // %%% PERFORMANCE + for (int gi = 0; gi < groups.Count; gi++) + { + ChatMessageGroup group = groups[gi]; + + // Always keep system messages; keep groups at or after the window start + if (group.Kind == ChatMessageGroupKind.System || gi >= firstKeptGroupIndex) + { + for (int j = group.StartIndex; j < group.StartIndex + group.Count; j++) + { + result.Add(messageList[j]); + } + } + } + + return Task.FromResult>(result); + } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/SummarizationCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/SummarizationCompactionStrategy.cs new file mode 100644 index 0000000000..028077beae --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/SummarizationCompactionStrategy.cs @@ -0,0 +1,166 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +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 concise summary message that preserves key facts and context. +/// +/// +/// +/// This strategy sits between tool-result clearing (gentle) and truncation (aggressive) in the +/// compaction ladder. Unlike truncation which discards messages entirely, summarization preserves +/// the essential information in compressed form, allowing the agent to maintain awareness of +/// earlier context. +/// +/// +/// The strategy protects system messages and the most recent preserveRecentGroups +/// 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. +/// +/// +public class SummarizationCompactionStrategy : ChatHistoryCompactionStrategy +{ + private readonly int _maxTokens; + + /// + /// 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 maximum token budget. Summarization is triggered when the token count exceeds this value. + /// + /// The number of most-recent non-system message groups to protect from summarization. + /// Defaults to 4, preserving the current and recent exchanges. + /// + /// + /// An optional custom system prompt for the summarization LLM call. When , + /// a default prompt that emphasizes fact-preservation is used. + /// + public SummarizationCompactionStrategy( + IChatClient chatClient, + int maxTokens, + int preserveRecentGroups = 4, + string? summarizationPrompt = null) + : base(new SummarizationReducer(chatClient, preserveRecentGroups, summarizationPrompt ?? DefaultSummarizationPrompt)) + { + this._maxTokens = maxTokens; + } + + /// + protected override bool ShouldCompact(ChatHistoryMetric metrics) => + metrics.TokenCount > this._maxTokens; + + /// + /// An that sends older message groups to an LLM for summarization, + /// then replaces them with a single summary message. + /// + private sealed class SummarizationReducer : IChatReducer + { + private readonly IChatClient _chatClient; + private readonly int _preserveRecentGroups; + private readonly string _summarizationPrompt; + + public SummarizationReducer(IChatClient chatClient, int preserveRecentGroups, string summarizationPrompt) + { + this._chatClient = Throw.IfNull(chatClient); + this._preserveRecentGroups = preserveRecentGroups; + this._summarizationPrompt = Throw.IfNullOrEmpty(summarizationPrompt); + } + + public async Task> ReduceAsync( + IEnumerable messages, + CancellationToken cancellationToken = default) + { + IReadOnlyList messageList = [.. messages]; + IReadOnlyList groups = CurrentMetrics.Groups; + + List nonSystemGroups = [.. groups.Where(g => g.Kind != ChatMessageGroupKind.System)]; + int protectedFromIndex = Math.Max(0, nonSystemGroups.Count - this._preserveRecentGroups); + + if (protectedFromIndex == 0) + { + // Nothing to summarize — all groups are protected + return messageList; + } + + // Collect messages from groups that will be summarized + List toSummarize = []; + for (int i = 0; i < protectedFromIndex; i++) + { + ChatMessageGroup group = nonSystemGroups[i]; + for (int j = group.StartIndex; j < group.StartIndex + group.Count; j++) + { + toSummarize.Add(messageList[j]); + } + } + + if (toSummarize.Count == 0) + { + return messageList; + } + + // Build the summarization request + List summarizationRequest = + [ + new(ChatRole.System, this._summarizationPrompt), + .. toSummarize, + new(ChatRole.User, "Summarize the conversation above concisely."), + ]; + + ChatResponse response = await this._chatClient.GetResponseAsync(summarizationRequest, cancellationToken: cancellationToken).ConfigureAwait(false); + string summaryText = string.IsNullOrWhiteSpace(response.Text) ? "[Summary unavailable]" : response.Text; + + // Build result: system groups + summary + protected groups + List result = []; + + // Keep system messages + foreach (ChatMessageGroup group in groups) + { + if (group.Kind == ChatMessageGroupKind.System) + { + for (int j = group.StartIndex; j < group.StartIndex + group.Count; j++) + { + result.Add(messageList[j]); + } + } + } + + // Insert summary + result.Add(new ChatMessage(ChatRole.Assistant, $"[Summary]\n{summaryText}")); + + // Keep protected groups + for (int i = protectedFromIndex; i < nonSystemGroups.Count; i++) + { + ChatMessageGroup group = nonSystemGroups[i]; + for (int j = group.StartIndex; j < group.StartIndex + group.Count; j++) + { + result.Add(messageList[j]); + } + } + + return result; + } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ToolResultCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ToolResultCompactionStrategy.cs new file mode 100644 index 0000000000..26380d6d6d --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ToolResultCompactionStrategy.cs @@ -0,0 +1,118 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Compaction; + +/// +/// A compaction strategy that collapses old assistant-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 +/// entries outside the protected recent window, replacing each multi-message group +/// (assistant call + tool results) with a single assistant message like +/// [Tool calls: get_weather, search_docs]. +/// +/// +/// The trigger condition fires only when token count exceeds maxTokens and +/// there is at least one tool call in the conversation. +/// +/// +public class ToolResultCompactionStrategy : ChatHistoryCompactionStrategy +{ + /// + /// The default value for `preserveRecentGroups` used when constructing . + /// + public const int DefaultPreserveRecentGroups = 2; + + private readonly int _maxTokens; + + /// + /// Initializes a new instance of the class. + /// + /// The maximum token budget. Tool groups are collapsed when the token count exceeds this value. + /// + /// The number of most-recent non-system message groups to protect from collapsing. + /// Defaults to 2, ensuring the current turn's tool interactions remain visible. + /// + public ToolResultCompactionStrategy(int maxTokens, int preserveRecentGroups = DefaultPreserveRecentGroups) + : base(new ToolResultClearingReducer(preserveRecentGroups)) + { + this._maxTokens = maxTokens; + } + + /// + protected override bool ShouldCompact(ChatHistoryMetric metrics) => + metrics.TokenCount > this._maxTokens && metrics.ToolCallCount > 0; + + /// + /// An that collapses + /// entries into single summary messages, preserving the most recent groups. + /// + private sealed class ToolResultClearingReducer(int preserveRecentGroups) : IChatReducer + { + public Task> ReduceAsync( + IEnumerable messages, + CancellationToken cancellationToken = default) + { + IReadOnlyList messageList = [.. messages]; + IReadOnlyList groups = CurrentMetrics.Groups; + + List nonSystemGroups = [.. groups.Where(g => g.Kind != ChatMessageGroupKind.System)]; + int protectedFromIndex = Math.Max(0, nonSystemGroups.Count - preserveRecentGroups); + HashSet protectedGroupStarts = []; + for (int i = protectedFromIndex; i < nonSystemGroups.Count; i++) + { + protectedGroupStarts.Add(nonSystemGroups[i].StartIndex); + } + + List result = new(messageList.Count); + bool anyCollapsed = false; + + foreach (ChatMessageGroup group in groups) + { + if (group.Kind == ChatMessageGroupKind.AssistantToolGroup && !protectedGroupStarts.Contains(group.StartIndex)) + { + // Collapse this tool group into a single summary message + List toolNames = []; + for (int j = group.StartIndex; j < group.StartIndex + group.Count; j++) + { + if (messageList[j].Contents is not null) + { + foreach (AIContent content in messageList[j].Contents) + { + if (content is FunctionCallContent fcc) + { + toolNames.Add(fcc.Name); + } + } + } + } + + string summary = $"[Tool calls: {string.Join(", ", toolNames)}]"; + result.Add(new ChatMessage(ChatRole.Assistant, summary)); + anyCollapsed = true; + } + else + { + // Keep this group as-is + for (int j = group.StartIndex; j < group.StartIndex + group.Count; j++) + { + result.Add(messageList[j]); + } + } + } + + return Task.FromResult>(anyCollapsed ? result : messageList); + } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/TruncationCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/TruncationCompactionStrategy.cs new file mode 100644 index 0000000000..a2a6b07acf --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/TruncationCompactionStrategy.cs @@ -0,0 +1,97 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Compaction; + +/// +/// A compaction strategy that removes the oldest message groups until the estimated +/// token count is within a specified budget. +/// +/// +/// +/// 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. +/// +/// +/// The trigger condition fires only when the current token count exceeds maxTokens. +/// +/// +public class TruncationCompactionStrategy : ChatHistoryCompactionStrategy +{ + private readonly int _maxTokens; + + /// + /// Initializes a new instance of the class. + /// + /// The maximum token budget. Groups are removed until the token count is at or below this value. + /// + /// The minimum number of most-recent non-system message groups to keep. + /// Defaults to 1 so that at least the latest exchange is always preserved. + /// + public TruncationCompactionStrategy(int maxTokens, int preserveRecentGroups = 1) + : base(new TruncationReducer(preserveRecentGroups)) + { + this._maxTokens = maxTokens; + } + + /// + protected override bool ShouldCompact(ChatHistoryMetric metrics) => + metrics.TokenCount > this._maxTokens; + + /// + /// An that removes the oldest non-system message groups, + /// keeping at least the most recent group. + /// + private sealed class TruncationReducer(int preserveRecentGroups) : IChatReducer + { + public Task> ReduceAsync( + IEnumerable messages, + CancellationToken cancellationToken = default) + { + IReadOnlyList messageList = [.. messages]; + + ChatMessageGroup[] removableGroups = [.. CurrentMetrics.Groups.Where(g => g.Kind != ChatMessageGroupKind.System)]; + + if (removableGroups.Length == 0) + { + return Task.FromResult>(messageList); + } + + // Remove oldest non-system groups, keeping at least preserveRecentGroups. + int maxRemovable = removableGroups.Length - preserveRecentGroups; + + if (maxRemovable <= 0) + { + return Task.FromResult>(messageList); + } + + HashSet removedGroupStarts = []; + for (int ri = 0; ri < maxRemovable; ri++) + { + removedGroupStarts.Add(removableGroups[ri].StartIndex); + } + + List messagesToKeep = new(messageList.Count); + foreach (ChatMessageGroup group in CurrentMetrics.Groups) + { + if (removedGroupStarts.Contains(group.StartIndex)) + { + continue; + } + + for (int j = group.StartIndex; j < group.StartIndex + group.Count; j++) + { + messagesToKeep.Add(messageList[j]); + } + } + + return Task.FromResult>(messagesToKeep); + } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Microsoft.Agents.AI.Abstractions.csproj b/dotnet/src/Microsoft.Agents.AI.Abstractions/Microsoft.Agents.AI.Abstractions.csproj index e31093e174..01fd5db648 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/Microsoft.Agents.AI.Abstractions.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Microsoft.Agents.AI.Abstractions.csproj @@ -31,6 +31,10 @@ + + + + diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/ChatHistoryCompactionPipelineTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/ChatHistoryCompactionPipelineTests.cs new file mode 100644 index 0000000000..75658054d1 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/ChatHistoryCompactionPipelineTests.cs @@ -0,0 +1,136 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Abstractions.UnitTests.Compaction.Internal; +using Microsoft.Agents.AI.Compaction; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction; + +public class ChatHistoryCompactionPipelineTests +{ + [Fact] + public async Task EmptyStrategies_ReturnsUnmodifiedAsync() + { + // Arrange + ChatHistoryCompactionPipeline pipeline = new([]); + List messages = [new(ChatRole.User, "Hello")]; + + // Act + CompactionPipelineResult result = await pipeline.CompactAsync(messages); + + // Assert + Assert.False(result.AnyApplied); + Assert.Equal(1, result.Before.MessageCount); + Assert.Equal(1, result.After.MessageCount); + Assert.Empty(result.StrategyResults); + } + + [Fact] + public async Task ChainsStrategies_InOrderAsync() + { + // Arrange + ChatHistoryCompactionStrategy[] strategies = + [ + new NeverCompactStrategy(), + new RemoveFirstMessageStrategy(), + ]; + ChatHistoryCompactionPipeline pipeline = new(strategies); + List messages = + [ + new(ChatRole.User, "First"), + new(ChatRole.User, "Second"), + ]; + + // Act + CompactionPipelineResult result = await pipeline.CompactAsync(messages); + + // Assert + Assert.True(result.AnyApplied); + Assert.Equal(2, result.StrategyResults.Count); + Assert.False(result.StrategyResults[0].Applied); + Assert.True(result.StrategyResults[1].Applied); + Assert.Single(messages); + } + + [Fact] + public async Task ReportsOverallMetricsAsync() + { + // Arrange + ChatHistoryCompactionPipeline pipeline = new([new RemoveFirstMessageStrategy()]); + List messages = + [ + new(ChatRole.User, "First"), + new(ChatRole.User, "Second"), + new(ChatRole.User, "Third"), + ]; + + // Act + CompactionPipelineResult result = await pipeline.CompactAsync(messages); + + // Assert + Assert.Equal(3, result.Before.MessageCount); + Assert.Equal(2, result.After.MessageCount); + } + + [Fact] + public async Task CustomMetricsCalculator_IsUsedAsync() + { + // Arrange + Moq.Mock calcMock = new(); + calcMock + .Setup(c => c.Calculate(Moq.It.IsAny>())) + .Returns(new ChatHistoryMetric { MessageCount = 42 }); + ChatHistoryCompactionPipeline pipeline = new(calcMock.Object, []); + List messages = [new(ChatRole.User, "Hello")]; + + // Act + CompactionPipelineResult result = await pipeline.CompactAsync(messages); + + // Assert + Assert.Equal(42, result.Before.MessageCount); + calcMock.Verify(c => c.Calculate(Moq.It.IsAny>()), Moq.Times.Once); + } + + [Fact] + public async Task ReduceAsync_DelegatesCompactionAsync() + { + // Arrange + ChatHistoryCompactionPipeline pipeline = new([new RemoveFirstMessageStrategy()]); + List messages = + [ + new(ChatRole.User, "First"), + new(ChatRole.User, "Second"), + new(ChatRole.User, "Third"), + ]; + + // Act + IEnumerable result = await pipeline.ReduceAsync(messages, default); + List resultList = result.ToList(); + + // Assert + Assert.Equal(2, resultList.Count); + Assert.Equal("Second", resultList[0].Text); + Assert.Equal("Third", resultList[1].Text); + } + + [Fact] + public async Task ReduceAsync_EmptyStrategies_ReturnsAllMessagesAsync() + { + // Arrange + ChatHistoryCompactionPipeline pipeline = new([]); + ChatMessage[] messages = + [ + new(ChatRole.User, "Hello"), + new(ChatRole.User, "World"), + ]; + + // Act + IEnumerable result = await pipeline.ReduceAsync(messages, default); + + // Assert + Assert.Equal(2, result.Count()); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/ChatHistoryCompactionStrategyTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/ChatHistoryCompactionStrategyTests.cs new file mode 100644 index 0000000000..a0635de76e --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/ChatHistoryCompactionStrategyTests.cs @@ -0,0 +1,144 @@ +// 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.Abstractions.UnitTests.Compaction.Internal; +using Microsoft.Agents.AI.Compaction; +using Microsoft.Extensions.AI; +using Moq; + +namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction; + +public class ChatHistoryCompactionStrategyTests +{ + [Fact] + public async Task ShouldCompactReturnsFalse_SkipsAsync() + { + // Arrange + List messages = [new(ChatRole.User, "Hello")]; + NeverCompactStrategy strategy = new(); + + // Act + CompactionResult result = await RunCompactionStrategyAsync(strategy, messages); + + // Assert + Assert.False(result.Applied); + } + + [Fact] + public async Task ShouldCompactReturnsTrue_RunsCompactionAsync() + { + // Arrange + List messages = + [ + new(ChatRole.User, "First"), + new(ChatRole.User, "Second"), + ]; + RemoveFirstMessageStrategy strategy = new(); + + // Act + CompactionResult result = await RunCompactionStrategyAsync(strategy, messages); + + // Assert + Assert.True(result.Applied); + Assert.Single(messages); + Assert.Equal("Second", messages[0].Text); + Assert.Equal(2, result.Before.MessageCount); + Assert.Equal(1, result.After.MessageCount); + } + + [Fact] + public async Task DelegatesToReducerAsync() + { + // Arrange + List messages = + [ + new(ChatRole.User, "First"), + new(ChatRole.User, "Second"), + ]; + Mock reducerMock = new(); + reducerMock + .Setup(r => r.ReduceAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync((IEnumerable messages, CancellationToken _) => messages.Skip(1)); + TestCompactionStrategy strategy = new(reducerMock.Object); + + // Act + CompactionResult result = await RunCompactionStrategyAsync(strategy, messages); + + // Assert + Assert.True(result.Applied); + Assert.Single(messages); + Assert.Equal("Second", messages[0].Text); + reducerMock.Verify(r => r.ReduceAsync(It.IsAny>(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task ReducerNoChange_ReturnsFalseAsync() + { + // Arrange + List messages = + [ + new(ChatRole.User, "Hello"), + ]; + Mock reducerMock = new(); + reducerMock + .Setup(r => r.ReduceAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync((IEnumerable msgs, CancellationToken _) => msgs); + TestCompactionStrategy strategy = new(reducerMock.Object, shouldCompact: false); + + // Act + CompactionResult result = await RunCompactionStrategyAsync(strategy, messages); + + // Assert + Assert.False(result.Applied); + Assert.Single(messages); + } + + [Fact] + public void ReducerLifecycle() + { + // Arrange + Mock reducerMock = new(); + + // Act + TestCompactionStrategy strategy = new(reducerMock.Object); + + // Assert + Assert.Same(reducerMock.Object, strategy.Reducer); + Assert.NotNull(strategy.Name); + Assert.NotEmpty(strategy.Name); + Assert.Equal(reducerMock.Object.GetType().Name, strategy.Name); + } + + [Fact] + public void CurrentMetrics_OutsideStrategy_Throws() + { + // Act & Assert + Assert.Throws(() => TestCompactionStrategy.GetCurrentMetrics()); + } + + public static async ValueTask RunCompactionStrategyAsync(ChatHistoryCompactionStrategy strategy, List messages) + { + // Act + ChatHistoryCompactionStrategy.s_currentMetrics.Value = DefaultChatHistoryMetricsCalculator.Instance.Calculate(messages); + return await strategy.CompactAsync(messages, DefaultChatHistoryMetricsCalculator.Instance); + } + + private sealed class TestCompactionStrategy : ChatHistoryCompactionStrategy + { + private readonly bool _shouldCompact; + + public TestCompactionStrategy(IChatReducer reducer, bool shouldCompact = true) + : base(reducer) + { + this._shouldCompact = shouldCompact; + } + + protected override bool ShouldCompact(ChatHistoryMetric metrics) => this._shouldCompact; + + public static ChatHistoryMetric GetCurrentMetrics() => CurrentMetrics; + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/ChatReducerCompactionStrategyTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/ChatReducerCompactionStrategyTests.cs new file mode 100644 index 0000000000..844f7f55ea --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/ChatReducerCompactionStrategyTests.cs @@ -0,0 +1,114 @@ +// 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.Abstractions.UnitTests.Compaction; + +public class ChatReducerCompactionStrategyTests : CompactionStrategyTestBase +{ + [Fact] + public async Task ConditionFalse_SkipsAsync() + { + // Arrange + List messages = [new(ChatRole.User, "Hello")]; + Mock reducerMock = new(); + ChatReducerCompactionStrategy strategy = new(reducerMock.Object, _ => false); + + // Act & Assert + await RunCompactionStrategySkippedAsync(strategy, messages); + + // Assert + reducerMock.Verify( + r => r.ReduceAsync(It.IsAny>(), It.IsAny()), + Times.Never); + } + + [Fact] + public async Task ConditionTrue_RunsReducerAsync() + { + // Arrange + List messages = + [ + new(ChatRole.User, "First"), + new(ChatRole.User, "Second"), + ]; + Mock reducerMock = new(); + reducerMock + .Setup(r => r.ReduceAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync((IEnumerable msgs, CancellationToken _) => msgs.Skip(1)); + ChatReducerCompactionStrategy strategy = new(reducerMock.Object, _ => true); + + // Act & Assert + await RunCompactionStrategyReducedAsync(strategy, messages, expectedCount: 1); + + // Assert + Assert.Equal("Second", messages[0].Text); + reducerMock.Verify( + r => r.ReduceAsync(It.IsAny>(), It.IsAny()), + Times.Once); + } + + [Fact] + public async Task ConditionReceivesMetricsAsync() + { + // Arrange + List messages = + [ + new(ChatRole.User, "Hello"), + new(ChatRole.Assistant, "Hi"), + ]; + ChatHistoryMetric? capturedMetrics = null; + Mock reducerMock = new(); + reducerMock + .Setup(r => r.ReduceAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync((IEnumerable msgs, CancellationToken _) => msgs); + ChatReducerCompactionStrategy strategy = new( + reducerMock.Object, + metrics => + { + capturedMetrics = metrics; + return false; + }); + + // Act & Assert + await RunCompactionStrategySkippedAsync(strategy, messages); + + // Assert + Assert.NotNull(capturedMetrics); + Assert.Equal(2, capturedMetrics!.MessageCount); + } + + [Fact] + public async Task ReducerNoChange_AppliedFalseAsync() + { + // Arrange + List messages = [new(ChatRole.User, "Hello")]; + Mock reducerMock = new(); + reducerMock + .Setup(r => r.ReduceAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync((IEnumerable msgs, CancellationToken _) => msgs); + ChatReducerCompactionStrategy strategy = new(reducerMock.Object, _ => true); + + // Act & Assert + await RunCompactionStrategySkippedAsync(strategy, messages); + } + + [Fact] + public void Name_ReturnsReducerTypeName() + { + // Arrange + Mock reducerMock = new(); + + // Act + ChatReducerCompactionStrategy strategy = new(reducerMock.Object, _ => true); + + // Assert + Assert.Equal(reducerMock.Object.GetType().Name, strategy.Name); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/CompactionMetricTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/CompactionMetricTests.cs new file mode 100644 index 0000000000..ace35e3607 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/CompactionMetricTests.cs @@ -0,0 +1,44 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI.Compaction; + +namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction; + +public class CompactionMetricTests +{ + [Fact] + public void DefaultValues_AreZero() + { + // Arrange & Act + ChatHistoryMetric metrics = new(); + + // Assert + Assert.Equal(0, metrics.TokenCount); + Assert.Equal(0L, metrics.ByteCount); + Assert.Equal(0, metrics.MessageCount); + Assert.Equal(0, metrics.ToolCallCount); + Assert.Equal(0, metrics.UserTurnCount); + Assert.Empty(metrics.Groups); + } + + [Fact] + public void InitProperties_SetCorrectly() + { + // Arrange & Act + ChatHistoryMetric metrics = new() + { + TokenCount = 100, + ByteCount = 500, + MessageCount = 5, + ToolCallCount = 2, + UserTurnCount = 3 + }; + + // Assert + Assert.Equal(100, metrics.TokenCount); + Assert.Equal(500L, metrics.ByteCount); + Assert.Equal(5, metrics.MessageCount); + Assert.Equal(2, metrics.ToolCallCount); + Assert.Equal(3, metrics.UserTurnCount); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/CompactionPipelineResultTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/CompactionPipelineResultTests.cs new file mode 100644 index 0000000000..7edc8426f5 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/CompactionPipelineResultTests.cs @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; + +using Microsoft.Agents.AI.Compaction; + +namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction; + +public class CompactionPipelineResultTests +{ + [Fact] + public void Properties_AreReadable() + { + // Arrange + ChatHistoryMetric before = new() { MessageCount = 10 }; + ChatHistoryMetric after = new() { MessageCount = 5 }; + CompactionResult strategyResult = new("Test", applied: true, before, after); + List results = [strategyResult]; + + // Act + CompactionPipelineResult pipelineResult = new(before, after, results); + + // Assert + Assert.Same(before, pipelineResult.Before); + Assert.Same(after, pipelineResult.After); + Assert.Single(pipelineResult.StrategyResults); + } + + [Fact] + public void AnyApplied_AllFalse_ReturnsFalse() + { + // Arrange + ChatHistoryMetric metrics = new() { MessageCount = 5 }; + CompactionResult skipped = CompactionResult.Skipped("Skip", metrics); + + // Act + CompactionPipelineResult result = new(metrics, metrics, [skipped]); + + // Assert + Assert.False(result.AnyApplied); + } + + [Fact] + public void AnyApplied_SomeTrue_ReturnsTrue() + { + // Arrange + ChatHistoryMetric before = new() { MessageCount = 10 }; + ChatHistoryMetric after = new() { MessageCount = 5 }; + CompactionResult applied = new("Applied", applied: true, before, after); + + // Act + CompactionPipelineResult result = new(before, after, [applied]); + + // Assert + Assert.True(result.AnyApplied); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/CompactionResultTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/CompactionResultTests.cs new file mode 100644 index 0000000000..bc36898a4d --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/CompactionResultTests.cs @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI.Compaction; + +namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction; + +public class CompactionResultTests +{ + [Fact] + public void Skipped_HasSameBeforeAndAfter() + { + // Arrange + ChatHistoryMetric metrics = new() { MessageCount = 5, TokenCount = 100 }; + + // Act + CompactionResult result = CompactionResult.Skipped("Test", metrics); + + // Assert + Assert.Equal("Test", result.StrategyName); + Assert.False(result.Applied); + Assert.Same(metrics, result.Before); + Assert.Same(metrics, result.After); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/CompactionStrategyTestBase.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/CompactionStrategyTestBase.cs new file mode 100644 index 0000000000..851713cc83 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/CompactionStrategyTestBase.cs @@ -0,0 +1,40 @@ +// 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.Abstractions.UnitTests.Compaction; + +public abstract class CompactionStrategyTestBase +{ + public static async ValueTask RunCompactionStrategyReducedAsync(ChatHistoryCompactionStrategy strategy, List messages, int expectedCount) + { + // Act + ChatHistoryCompactionStrategy.s_currentMetrics.Value = DefaultChatHistoryMetricsCalculator.Instance.Calculate(messages); + CompactionResult result = await strategy.CompactAsync(messages, DefaultChatHistoryMetricsCalculator.Instance); + + // Assert + Assert.True(result.Applied); + Assert.NotEqual(result.Before, result.After); + Assert.Equal(expectedCount, messages.Count); + + return result; + } + + public static async ValueTask RunCompactionStrategySkippedAsync(ChatHistoryCompactionStrategy strategy, List messages) + { + // Act + int initialCount = messages.Count; + ChatHistoryCompactionStrategy.s_currentMetrics.Value = DefaultChatHistoryMetricsCalculator.Instance.Calculate(messages); + CompactionResult result = await strategy.CompactAsync(messages, DefaultChatHistoryMetricsCalculator.Instance); + + // Assert + Assert.False(result.Applied); + Assert.Equal(result.Before, result.After); + Assert.Equal(initialCount, messages.Count); + + return result; + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/DefaultChatHistoryMetricsCalculatorTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/DefaultChatHistoryMetricsCalculatorTests.cs new file mode 100644 index 0000000000..2edbe6a4ff --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/DefaultChatHistoryMetricsCalculatorTests.cs @@ -0,0 +1,399 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using Microsoft.Agents.AI.Compaction; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction; + +public class DefaultChatHistoryMetricsCalculatorTests +{ + [Fact] + public void EmptyList_ReturnsZeroMetrics() + { + // Arrange + DefaultChatHistoryMetricsCalculator calculator = new(); + + // Act + ChatHistoryMetric metrics = calculator.Calculate([]); + + // Assert + Assert.Equal(0, metrics.TokenCount); + Assert.Equal(0L, metrics.ByteCount); + Assert.Equal(0, metrics.MessageCount); + Assert.Equal(0, metrics.ToolCallCount); + Assert.Equal(0, metrics.UserTurnCount); + } + + [Fact] + public void CountsMessages() + { + // Arrange + DefaultChatHistoryMetricsCalculator calculator = new(); + List messages = + [ + new(ChatRole.User, "Hello"), + new(ChatRole.Assistant, "Hi there"), + ]; + + // Act + ChatHistoryMetric metrics = calculator.Calculate(messages); + + // Assert + Assert.Equal(2, metrics.MessageCount); + } + + [Fact] + public void CountsUserTurns() + { + // Arrange + DefaultChatHistoryMetricsCalculator calculator = new(); + List messages = + [ + new(ChatRole.User, "Hello"), + new(ChatRole.Assistant, "Hi"), + new(ChatRole.User, "How are you?"), + new(ChatRole.Assistant, "Good"), + ]; + + // Act + ChatHistoryMetric metrics = calculator.Calculate(messages); + + // Assert + Assert.Equal(2, metrics.UserTurnCount); + } + + [Fact] + public void CountsToolCalls() + { + // Arrange + DefaultChatHistoryMetricsCalculator calculator = new(); + ChatMessage assistantMsg = new(ChatRole.Assistant, [ + new FunctionCallContent("call1", "get_weather", new Dictionary { ["city"] = "NYC" }), + new FunctionCallContent("call2", "get_time"), + ]); + List messages = + [ + new(ChatRole.User, "What's the weather?"), + assistantMsg, + ]; + + // Act + ChatHistoryMetric metrics = calculator.Calculate(messages); + + // Assert + Assert.Equal(2, metrics.ToolCallCount); + } + + [Fact] + public void ConsecutiveUserMessages_CountAsOneTurn() + { + // Arrange + DefaultChatHistoryMetricsCalculator calculator = new(); + List messages = + [ + new(ChatRole.User, "First"), + new(ChatRole.User, "Second"), + new(ChatRole.Assistant, "Reply"), + ]; + + // Act + ChatHistoryMetric metrics = calculator.Calculate(messages); + + // Assert + Assert.Equal(1, metrics.UserTurnCount); + } + + [Fact] + public void TokenCount_IsPositive() + { + // Arrange + DefaultChatHistoryMetricsCalculator calculator = new(); + List messages = + [ + new(ChatRole.User, "Hello world"), + ]; + + // Act + ChatHistoryMetric metrics = calculator.Calculate(messages); + + // Assert + Assert.True(metrics.TokenCount > 0); + Assert.True(metrics.ByteCount > 0); + } + + [Fact] + public void NullInput_ReturnsZeroMetrics() + { + // Arrange + DefaultChatHistoryMetricsCalculator calculator = new(); + + // Act + ChatHistoryMetric metrics = calculator.Calculate(null!); + + // Assert + Assert.Equal(0, metrics.TokenCount); + Assert.Equal(0L, metrics.ByteCount); + Assert.Equal(0, metrics.MessageCount); + Assert.Empty(metrics.Groups); + } + + [Fact] + public void InvalidCharsPerToken_UsesDefault() + { + // Arrange + DefaultChatHistoryMetricsCalculator calculator = new(charsPerToken: 0); + List messages = + [ + new(ChatRole.User, "Hello world"), + ]; + + // Act + ChatHistoryMetric metrics = calculator.Calculate(messages); + + // Assert + Assert.True(metrics.TokenCount > 0); + } + + [Fact] + public void NullMessageText_HandledGracefully() + { + // Arrange + DefaultChatHistoryMetricsCalculator calculator = new(); + ChatMessage msg = new() { Role = ChatRole.User }; + List messages = [msg]; + + // Act + ChatHistoryMetric metrics = calculator.Calculate(messages); + + // Assert + Assert.Equal(1, metrics.MessageCount); + Assert.True(metrics.TokenCount > 0); + Assert.Equal(0L, metrics.ByteCount); + } + + [Fact] + public void NullContents_SkipsToolCounting() + { + // Arrange + DefaultChatHistoryMetricsCalculator calculator = new(); + ChatMessage msg = new(ChatRole.User, "text"); + msg.Contents = null!; + List messages = [msg]; + + // Act + ChatHistoryMetric metrics = calculator.Calculate(messages); + + // Assert + Assert.Equal(1, metrics.MessageCount); + Assert.Equal(0, metrics.ToolCallCount); + } + + [Fact] + public void MessageWithOnlyNonTextContent_NullTextHandled() + { + // Arrange + DefaultChatHistoryMetricsCalculator calculator = new(); + ChatMessage msg = new(ChatRole.Assistant, + [ + new FunctionCallContent("c1", "func"), + ]); + List messages = [msg]; + + // Act + ChatHistoryMetric metrics = calculator.Calculate(messages); + + // Assert + Assert.Equal(1, metrics.MessageCount); + Assert.Equal(1, metrics.ToolCallCount); + } + + [Fact] + public void Calculate_PopulatesGroupIndex() + { + // Arrange + DefaultChatHistoryMetricsCalculator calculator = new(); + List messages = + [ + new(ChatRole.System, "System prompt"), + new(ChatRole.User, "Hello"), + new(ChatRole.Assistant, "Hi there"), + ]; + + // Act + ChatHistoryMetric metrics = calculator.Calculate(messages); + + // Assert + Assert.Equal(3, metrics.Groups.Count); + Assert.Equal(ChatMessageGroupKind.System, metrics.Groups[0].Kind); + Assert.Equal(ChatMessageGroupKind.UserTurn, metrics.Groups[1].Kind); + Assert.Equal(ChatMessageGroupKind.AssistantPlain, metrics.Groups[2].Kind); + } + + [Fact] + public void EmptyList_GroupIndexIsEmpty() + { + // Arrange + DefaultChatHistoryMetricsCalculator calculator = new(); + + // Act + ChatHistoryMetric metrics = calculator.Calculate([]); + + // Assert + Assert.Empty(metrics.Groups); + } + + [Fact] + public void GroupIndex_SystemMessage_IdentifiedCorrectly() + { + // Arrange + DefaultChatHistoryMetricsCalculator calculator = new(); + List messages = + [ + new(ChatRole.System, "You are a helpful assistant"), + ]; + + // Act + IReadOnlyList groups = calculator.Calculate(messages).Groups; + + // Assert + Assert.Single(groups); + Assert.Equal(ChatMessageGroupKind.System, groups[0].Kind); + Assert.Equal(0, groups[0].StartIndex); + Assert.Equal(1, groups[0].Count); + } + + [Fact] + public void GroupIndex_AssistantWithToolCalls_GroupedWithResults() + { + // Arrange + DefaultChatHistoryMetricsCalculator calculator = new(); + ChatMessage assistantMsg = new(ChatRole.Assistant, [ + new FunctionCallContent("call1", "get_weather", new Dictionary { ["city"] = "NYC" }), + ]); + ChatMessage toolResult = new(ChatRole.Tool, [ + new FunctionResultContent("call1", "Sunny, 72°F"), + ]); + List messages = + [ + new(ChatRole.User, "What's the weather?"), + assistantMsg, + toolResult, + ]; + + // Act + IReadOnlyList groups = calculator.Calculate(messages).Groups; + + // Assert + Assert.Equal(2, groups.Count); + Assert.Equal(ChatMessageGroupKind.UserTurn, groups[0].Kind); + Assert.Equal(ChatMessageGroupKind.AssistantToolGroup, groups[1].Kind); + Assert.Equal(1, groups[1].StartIndex); + Assert.Equal(2, groups[1].Count); + } + + [Fact] + public void GroupIndex_MultipleToolResults_GroupedTogether() + { + // Arrange + DefaultChatHistoryMetricsCalculator calculator = new(); + ChatMessage assistantMsg = new(ChatRole.Assistant, [ + new FunctionCallContent("c1", "func1"), + new FunctionCallContent("c2", "func2"), + ]); + ChatMessage tool1 = new(ChatRole.Tool, [new FunctionResultContent("c1", "result1")]); + ChatMessage tool2 = new(ChatRole.Tool, [new FunctionResultContent("c2", "result2")]); + List messages = [assistantMsg, tool1, tool2]; + + // Act + IReadOnlyList groups = calculator.Calculate(messages).Groups; + + // Assert + Assert.Single(groups); + Assert.Equal(ChatMessageGroupKind.AssistantToolGroup, groups[0].Kind); + Assert.Equal(3, groups[0].Count); + } + + [Fact] + public void GroupIndex_ComplexConversation_CorrectGrouping() + { + // Arrange + DefaultChatHistoryMetricsCalculator calculator = new(); + List messages = + [ + new(ChatRole.System, "You are a helper"), + new(ChatRole.User, "Hi"), + new(ChatRole.Assistant, "Hello!"), + new(ChatRole.User, "Get weather"), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "get_weather")]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c1", "Sunny")]), + new(ChatRole.Assistant, "It's sunny!"), + ]; + + // Act + IReadOnlyList groups = calculator.Calculate(messages).Groups; + + // Assert + Assert.Equal(6, groups.Count); + Assert.Equal(ChatMessageGroupKind.System, groups[0].Kind); + Assert.Equal(ChatMessageGroupKind.UserTurn, groups[1].Kind); + Assert.Equal(ChatMessageGroupKind.AssistantPlain, groups[2].Kind); + Assert.Equal(ChatMessageGroupKind.UserTurn, groups[3].Kind); + Assert.Equal(ChatMessageGroupKind.AssistantToolGroup, groups[4].Kind); + Assert.Equal(2, groups[4].Count); + Assert.Equal(ChatMessageGroupKind.AssistantPlain, groups[5].Kind); + } + + [Fact] + public void GroupIndex_OrphanToolResult_IdentifiedCorrectly() + { + // Arrange + DefaultChatHistoryMetricsCalculator calculator = new(); + List messages = + [ + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c1", "orphan result")]), + ]; + + // Act + IReadOnlyList groups = calculator.Calculate(messages).Groups; + + // Assert + Assert.Single(groups); + Assert.Equal(ChatMessageGroupKind.ToolResult, groups[0].Kind); + } + + [Fact] + public void GroupIndex_UnknownRole_IdentifiedAsOther() + { + // Arrange + DefaultChatHistoryMetricsCalculator calculator = new(); + List messages = + [ + new(new ChatRole("custom"), "custom message"), + ]; + + // Act + IReadOnlyList groups = calculator.Calculate(messages).Groups; + + // Assert + Assert.Single(groups); + Assert.Equal(ChatMessageGroupKind.Other, groups[0].Kind); + } + + [Fact] + public void GroupIndex_AssistantWithNullContents_ClassifiedAsPlain() + { + // Arrange + DefaultChatHistoryMetricsCalculator calculator = new(); + ChatMessage msg = new(ChatRole.Assistant, "reply"); + msg.Contents = null!; + List messages = [msg]; + + // Act + IReadOnlyList groups = calculator.Calculate(messages).Groups; + + // Assert + Assert.Single(groups); + Assert.Equal(ChatMessageGroupKind.AssistantPlain, groups[0].Kind); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/Internal/AgentRunContextHarness.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/Internal/AgentRunContextHarness.cs new file mode 100644 index 0000000000..6d9ae2eac1 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/Internal/AgentRunContextHarness.cs @@ -0,0 +1,52 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction.Internal; + +/// +/// Provides a way to set in unit tests +/// so that the underlying AsyncLocal is populated for code that reads it. +/// +internal static class AgentRunContextHarness +{ + private static readonly ContextAgentShim s_instance = new(); + + /// + /// Sets and invokes the provided action. + /// + public static void ExecuteWithRunContext(AgentRunContext context, Action action) + { + Assert.NotNull(context); + Assert.NotNull(action); + //AgentRunContext context = new(agent, session, messages ?? [], options); // %%% TODO + s_instance.Set(context); + action.Invoke(); + } + + // Derived class that exposes the protected setter. + private sealed class ContextAgentShim : AIAgent + { + public void Set(AgentRunContext? value) => CurrentRunContext = value; + + protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + protected override ValueTask SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + protected override ValueTask DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + protected override Task RunCoreAsync(IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + protected override IAsyncEnumerable RunCoreStreamingAsync(IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/Internal/NeverCompactStrategy.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/Internal/NeverCompactStrategy.cs new file mode 100644 index 0000000000..f126ed5db2 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/Internal/NeverCompactStrategy.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Compaction; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction.Internal; + +internal sealed class NeverCompactStrategy : ChatHistoryCompactionStrategy +{ + public NeverCompactStrategy() + : base(new NoOpReducer()) + { + } + + public override string Name => "NeverCompact"; + + protected override bool ShouldCompact(ChatHistoryMetric metrics) => false; + + private sealed class NoOpReducer : IChatReducer + { + public Task> ReduceAsync(IEnumerable messages, CancellationToken cancellationToken = default) + => Task.FromResult(messages); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/Internal/RemoveFirstMessageStrategy.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/Internal/RemoveFirstMessageStrategy.cs new file mode 100644 index 0000000000..477a7815a0 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/Internal/RemoveFirstMessageStrategy.cs @@ -0,0 +1,36 @@ +// 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; + +namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction.Internal; + +internal sealed class RemoveFirstMessageStrategy : ChatHistoryCompactionStrategy +{ + public RemoveFirstMessageStrategy() + : base(new RemoveFirstReducer()) + { + } + + public override string Name => "RemoveFirst"; + + protected override bool ShouldCompact(ChatHistoryMetric metrics) => metrics.MessageCount > 0; + + private sealed class RemoveFirstReducer : IChatReducer + { + public Task> ReduceAsync(IEnumerable messages, CancellationToken cancellationToken = default) + { + List list = messages.ToList(); + if (list.Count > 1) + { + list.RemoveAt(0); + } + + return Task.FromResult>(list); + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/MessageGroupTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/MessageGroupTests.cs new file mode 100644 index 0000000000..c84ebbb1ac --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/MessageGroupTests.cs @@ -0,0 +1,65 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI.Compaction; + +namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction; + +public class MessageGroupTests +{ + [Fact] + public void Equality_Works() + { + // Arrange + ChatMessageGroup a = new(0, 2, ChatMessageGroupKind.AssistantToolGroup); + ChatMessageGroup b = new(0, 2, ChatMessageGroupKind.AssistantToolGroup); + ChatMessageGroup c = new(1, 2, ChatMessageGroupKind.AssistantToolGroup); + + // Act & Assert + Assert.Equal(a, b); + Assert.True(a == b); + Assert.NotEqual(a, c); + Assert.True(a != c); + } + + [Fact] + public void Equals_Object_NullReturnsFalse() + { + // Arrange + ChatMessageGroup group = new(0, 1, ChatMessageGroupKind.System); + + // Act & Assert + Assert.False(group.Equals(null)); + } + + [Fact] + public void Equals_Object_BoxedMessageGroupReturnsTrue() + { + // Arrange + ChatMessageGroup group = new(0, 2, ChatMessageGroupKind.AssistantToolGroup); + object boxed = new ChatMessageGroup(0, 2, ChatMessageGroupKind.AssistantToolGroup); + + // Act & Assert + Assert.True(group.Equals(boxed)); + } + + [Fact] + public void Equals_Object_WrongTypeReturnsFalse() + { + // Arrange + ChatMessageGroup group = new(0, 1, ChatMessageGroupKind.System); + + // Act & Assert + Assert.False(group.Equals("not a MessageGroup")); + } + + [Fact] + public void GetHashCode_ConsistentForEqualInstances() + { + // Arrange + ChatMessageGroup a = new(0, 2, ChatMessageGroupKind.AssistantToolGroup); + ChatMessageGroup b = new(0, 2, ChatMessageGroupKind.AssistantToolGroup); + + // Act & Assert + Assert.Equal(a.GetHashCode(), b.GetHashCode()); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/SlidingWindowCompactionStrategyTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/SlidingWindowCompactionStrategyTests.cs new file mode 100644 index 0000000000..b3e75920ba --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/SlidingWindowCompactionStrategyTests.cs @@ -0,0 +1,135 @@ +// 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.Abstractions.UnitTests.Compaction; + +public class SlidingWindowCompactionStrategyTests : CompactionStrategyTestBase +{ + [Fact] + public async Task UnderLimit_NoChangeAsync() + { + // Arrange + List messages = + [ + new(ChatRole.User, "Hello"), + new(ChatRole.Assistant, "Hi"), + ]; + SlidingWindowCompactionStrategy strategy = new(maxTurns: 10); + + // Act & Assert + await RunCompactionStrategySkippedAsync(strategy, messages); + } + + [Fact] + public async Task KeepsLastNTurnsAsync() + { + // Arrange + List messages = + [ + new(ChatRole.User, "Turn 1"), + new(ChatRole.Assistant, "Reply 1"), + new(ChatRole.User, "Turn 2"), + new(ChatRole.Assistant, "Reply 2"), + new(ChatRole.User, "Turn 3"), + new(ChatRole.Assistant, "Reply 3"), + ]; + SlidingWindowCompactionStrategy strategy = new(maxTurns: 2); + + // Act & Assert + await RunCompactionStrategyReducedAsync(strategy, messages, expectedCount: 4); + + // Assert + Assert.Equal("Turn 2", messages[0].Text); + Assert.Equal("Reply 2", messages[1].Text); + Assert.Equal("Turn 3", messages[2].Text); + Assert.Equal("Reply 3", messages[3].Text); + } + + [Fact] + public async Task PreservesSystemMessagesAsync() + { + // Arrange + List messages = + [ + new(ChatRole.System, "You are a helper"), + new(ChatRole.User, "Turn 1"), + new(ChatRole.Assistant, "Reply 1"), + new(ChatRole.User, "Turn 2"), + new(ChatRole.Assistant, "Reply 2"), + ]; + SlidingWindowCompactionStrategy strategy = new(maxTurns: 1); + + // Act & Assert + await RunCompactionStrategyReducedAsync(strategy, messages, expectedCount: 3); + + // Assert + Assert.Equal(ChatRole.System, messages[0].Role); + Assert.Equal("You are a helper", messages[0].Text); + Assert.Equal("Turn 2", messages[1].Text); + Assert.Equal("Reply 2", messages[2].Text); + } + + [Fact] + public async Task PreservesToolGroupsWithinKeptTurnsAsync() + { + // Arrange + List messages = + [ + new(ChatRole.User, "Turn 1"), + new(ChatRole.Assistant, "Reply 1"), + new(ChatRole.User, "Get weather"), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "get_weather")]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c1", "Sunny")]), + new(ChatRole.Assistant, "It's sunny!"), + ]; + SlidingWindowCompactionStrategy strategy = new(maxTurns: 1); + + // Act & Assert + await RunCompactionStrategyReducedAsync(strategy, messages, expectedCount: 4); + + // Assert + Assert.Equal("Get weather", messages[0].Text); + } + + [Fact] + public async Task SingleTurn_AtLimit_NoChangeAsync() + { + // Arrange + List messages = + [ + new(ChatRole.User, "Hello"), + new(ChatRole.Assistant, "Hi"), + ]; + SlidingWindowCompactionStrategy strategy = new(maxTurns: 1); + + // Act & Assert + await RunCompactionStrategySkippedAsync(strategy, messages); + } + + [Fact] + public async Task DropsResponseGroupsFromOldTurnsAsync() + { + // Arrange + List messages = + [ + new(ChatRole.User, "Turn 1"), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "search")]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c1", "result")]), + new(ChatRole.Assistant, "Here's what I found"), + new(ChatRole.User, "Turn 2"), + new(ChatRole.Assistant, "Reply 2"), + ]; + SlidingWindowCompactionStrategy strategy = new(maxTurns: 1); + + // Act & Assert + await RunCompactionStrategyReducedAsync(strategy, messages, expectedCount: 2); + + // Assert + Assert.Equal("Turn 2", messages[0].Text); + Assert.Equal("Reply 2", messages[1].Text); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/SummarizationCompactionStrategyTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/SummarizationCompactionStrategyTests.cs new file mode 100644 index 0000000000..018d35ce48 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/SummarizationCompactionStrategyTests.cs @@ -0,0 +1,164 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Compaction; +using Microsoft.Extensions.AI; +using Moq; + +namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction; + +public class SummarizationCompactionStrategyTests : CompactionStrategyTestBase +{ + [Fact] + public async Task UnderLimit_NoChangeAsync() + { + // Arrange + List messages = + [ + new(ChatRole.User, "Hello"), + new(ChatRole.Assistant, "Hi"), + ]; + Mock chatClientMock = new(); + SummarizationCompactionStrategy strategy = new(chatClientMock.Object, maxTokens: 100000); + + // Act & Assert + await RunCompactionStrategySkippedAsync(strategy, messages); + + // Assert + chatClientMock.Verify( + c => c.GetResponseAsync(It.IsAny>(), It.IsAny(), It.IsAny()), + Times.Never); + } + + [Fact] + public async Task SummarizesOldGroupsAsync() + { + // Arrange + List messages = + [ + new(ChatRole.User, "What's the weather?"), + new(ChatRole.Assistant, "The weather is sunny and 72°F."), + new(ChatRole.User, "How about tomorrow?"), + new(ChatRole.Assistant, "Tomorrow will be cloudy."), + new(ChatRole.User, "Thanks!"), + new(ChatRole.Assistant, "You're welcome!"), + ]; + Mock chatClientMock = new(); + chatClientMock + .Setup(c => c.GetResponseAsync(It.IsAny>(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "User asked about weather. It was sunny."))); + SummarizationCompactionStrategy strategy = new(chatClientMock.Object, maxTokens: 1, preserveRecentGroups: 2); + + // Act & Assert + await RunCompactionStrategyReducedAsync(strategy, messages, expectedCount: 3); + + // Assert + Assert.Contains("[Summary]", messages[0].Text); + Assert.Contains("sunny", messages[0].Text); + Assert.Equal("Thanks!", messages[1].Text); + Assert.Equal("You're welcome!", messages[2].Text); + } + + [Fact] + public async Task PreservesSystemMessagesAsync() + { + // Arrange + List messages = + [ + new(ChatRole.System, "You are a helper"), + new(ChatRole.User, "Turn 1"), + new(ChatRole.Assistant, "Reply 1"), + new(ChatRole.User, "Turn 2"), + new(ChatRole.Assistant, "Reply 2"), + ]; + Mock chatClientMock = new(); + chatClientMock + .Setup(c => c.GetResponseAsync(It.IsAny>(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "Summary of earlier discussion."))); + SummarizationCompactionStrategy strategy = new(chatClientMock.Object, maxTokens: 1, preserveRecentGroups: 1); + + // Act & Assert + await RunCompactionStrategyReducedAsync(strategy, messages, expectedCount: 3); + + // Assert + Assert.Equal(ChatRole.System, messages[0].Role); + Assert.Equal("You are a helper", messages[0].Text); + Assert.Contains("[Summary]", messages[1].Text); + } + + [Fact] + public async Task AllGroupsProtected_NoChangeAsync() + { + // Arrange + List messages = + [ + new(ChatRole.User, "Hello"), + new(ChatRole.Assistant, "Hi"), + ]; + Mock chatClientMock = new(); + SummarizationCompactionStrategy strategy = new(chatClientMock.Object, maxTokens: 1, preserveRecentGroups: 10); + + // Act & Assert + await RunCompactionStrategySkippedAsync(strategy, messages); + + // Assert + chatClientMock.Verify( + c => c.GetResponseAsync(It.IsAny>(), It.IsAny(), It.IsAny()), + Times.Never); + } + + [Fact] + public async Task CustomPrompt_UsedInRequestAsync() + { + // Arrange + const string CustomPrompt = "Summarize briefly."; + List messages = + [ + new(ChatRole.User, "First"), + new(ChatRole.Assistant, "Reply"), + new(ChatRole.User, "Second"), + new(ChatRole.Assistant, "Reply 2"), + ]; + List? capturedMessages = null; + Mock chatClientMock = new(); + chatClientMock + .Setup(c => c.GetResponseAsync(It.IsAny>(), It.IsAny(), It.IsAny())) + .Callback, ChatOptions, CancellationToken>((msgs, _, _) => capturedMessages = [.. msgs]) + .ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "Brief summary."))); + SummarizationCompactionStrategy strategy = new(chatClientMock.Object, maxTokens: 1, preserveRecentGroups: 1, summarizationPrompt: CustomPrompt); + + // Act & Assert + await RunCompactionStrategyReducedAsync(strategy, messages, expectedCount: 2); + + // Assert + Assert.NotNull(capturedMessages); + Assert.Equal(ChatRole.System, capturedMessages![0].Role); + Assert.Equal(CustomPrompt, capturedMessages[0].Text); + } + + [Fact] + public async Task NullResponseText_UsesFallbackAsync() + { + // Arrange + List messages = + [ + new(ChatRole.User, "First"), + new(ChatRole.Assistant, "Reply"), + new(ChatRole.User, "Second"), + new(ChatRole.Assistant, "Reply 2"), + ]; + Mock chatClientMock = new(); + chatClientMock + .Setup(c => c.GetResponseAsync(It.IsAny>(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, (string?)null))); + SummarizationCompactionStrategy strategy = new(chatClientMock.Object, maxTokens: 1, preserveRecentGroups: 1); + + // Act & Assert + await RunCompactionStrategyReducedAsync(strategy, messages, expectedCount: 2); + + // Assert + Assert.Contains("[Summary unavailable]", messages[0].Text); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/ToolResultCompactionStrategyTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/ToolResultCompactionStrategyTests.cs new file mode 100644 index 0000000000..d2b3113ec6 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/ToolResultCompactionStrategyTests.cs @@ -0,0 +1,131 @@ +// 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.Abstractions.UnitTests.Compaction; + +public class ToolResultCompactionStrategyTests : CompactionStrategyTestBase +{ + [Fact] + public async Task UnderLimit_NoChangeAsync() + { + // Arrange + List messages = + [ + new(ChatRole.User, "Hello"), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "get_weather")]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c1", "Sunny")]), + ]; + ToolResultCompactionStrategy strategy = new(maxTokens: 100000); + + // Act & Assert + await RunCompactionStrategySkippedAsync(strategy, messages); + } + + [Fact] + public async Task CollapsesOldToolGroupAsync() + { + // Arrange + List messages = + [ + new(ChatRole.User, "Check weather"), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "get_weather")]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c1", "Sunny, 72°F")]), + new(ChatRole.User, "Thanks"), + new(ChatRole.Assistant, "You're welcome!"), + ]; + ToolResultCompactionStrategy strategy = new(maxTokens: 1, preserveRecentGroups: 1); + + // Act & Assert + await RunCompactionStrategyReducedAsync(strategy, messages, expectedCount: 4); + + // Assert + Assert.Contains("[Tool calls: get_weather]", messages[1].Text); + Assert.Equal(ChatRole.Assistant, messages[1].Role); + } + + [Fact] + public async Task ProtectsRecentGroupsAsync() + { + // Arrange + List messages = + [ + new(ChatRole.User, "Check weather"), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "get_weather")]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c1", "Sunny")]), + new(ChatRole.User, "Thanks"), + new(ChatRole.Assistant, "You're welcome!"), + ]; + ToolResultCompactionStrategy strategy = new(maxTokens: 1, preserveRecentGroups: 10); + + // Act & Assert + await RunCompactionStrategySkippedAsync(strategy, messages); + } + + [Fact] + public async Task PreservesSystemMessagesAsync() + { + // Arrange + List messages = + [ + new(ChatRole.System, "You are a helper"), + new(ChatRole.User, "Check weather"), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "get_weather")]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c1", "Sunny")]), + new(ChatRole.User, "Thanks"), + new(ChatRole.Assistant, "You're welcome!"), + ]; + ToolResultCompactionStrategy strategy = new(maxTokens: 1, preserveRecentGroups: 1); + + // Act & Assert + await RunCompactionStrategyReducedAsync(strategy, messages, expectedCount: 5); + + // Assert + Assert.Equal(ChatRole.System, messages[0].Role); + Assert.Equal("You are a helper", messages[0].Text); + } + + [Fact] + public async Task MultipleToolCalls_ListedInSummaryAsync() + { + // Arrange + List messages = + [ + new(ChatRole.User, "Do research"), + new ChatMessage(ChatRole.Assistant, [ + new FunctionCallContent("c1", "search"), + new FunctionCallContent("c2", "fetch_page"), + ]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c1", "results...")]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c2", "page content...")]), + new(ChatRole.User, "Summarize"), + new(ChatRole.Assistant, "Here's the summary."), + ]; + ToolResultCompactionStrategy strategy = new(maxTokens: 1, preserveRecentGroups: 1); + + // Act & Assert + await RunCompactionStrategyReducedAsync(strategy, messages, expectedCount: 4); + + // Assert + Assert.Contains("search", messages[1].Text); + Assert.Contains("fetch_page", messages[1].Text); + } + + [Fact] + public async Task NoToolGroups_NoChangeAsync() + { + // Arrange + List messages = + [ + new(ChatRole.User, "Hello"), + new(ChatRole.Assistant, "Hi there"), + ]; + ToolResultCompactionStrategy strategy = new(maxTokens: 1); + + // Act & Assert + await RunCompactionStrategySkippedAsync(strategy, messages); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/TruncationCompactionStrategyTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/TruncationCompactionStrategyTests.cs new file mode 100644 index 0000000000..b4be4422a5 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/TruncationCompactionStrategyTests.cs @@ -0,0 +1,93 @@ +// 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.Abstractions.UnitTests.Compaction; + +public class TruncationCompactionStrategyTests : CompactionStrategyTestBase +{ + [Fact] + public async Task UnderLimit_NoChangeAsync() + { + // Arrange + List messages = + [ + new(ChatRole.User, "Hello"), + ]; + TruncationCompactionStrategy strategy = new(maxTokens: 100000); + + // Act & Assert + await RunCompactionStrategySkippedAsync(strategy, messages); + } + + [Fact] + public async Task OverLimit_RemovesOldestGroupsAsync() + { + // Arrange + List messages = + [ + new(ChatRole.User, "First message"), + new(ChatRole.Assistant, "First reply"), + new(ChatRole.User, "Second message"), + new(ChatRole.Assistant, "Second reply"), + ]; + TruncationCompactionStrategy strategy = new(maxTokens: 1); + + // Act & Assert + await RunCompactionStrategyReducedAsync(strategy, messages, expectedCount: 1); + } + + [Fact] + public async Task SystemOnlyMessages_NoChangeAsync() + { + // Arrange + List messages = + [ + new(ChatRole.System, "You are a helper"), + ]; + TruncationCompactionStrategy strategy = new(maxTokens: 1); + + // Act & Assert + await RunCompactionStrategySkippedAsync(strategy, messages); + } + + [Fact] + public async Task SingleNonSystemGroup_NoChangeAsync() + { + // Arrange + List messages = + [ + new(ChatRole.System, "System prompt"), + new(ChatRole.User, "Only user message"), + ]; + TruncationCompactionStrategy strategy = new(maxTokens: 1); + + // Act & Assert + await RunCompactionStrategySkippedAsync(strategy, messages); + } + + [Fact] + public async Task PreserveRecentGroups_KeepsMultipleGroupsAsync() + { + // Arrange + List messages = + [ + new(ChatRole.User, "Turn 1"), + new(ChatRole.Assistant, "Reply 1"), + new(ChatRole.User, "Turn 2"), + new(ChatRole.Assistant, "Reply 2"), + new(ChatRole.User, "Turn 3"), + new(ChatRole.Assistant, "Reply 3"), + ]; + TruncationCompactionStrategy strategy = new(maxTokens: 1, preserveRecentGroups: 2); + + // Act & Assert + await RunCompactionStrategyReducedAsync(strategy, messages, expectedCount: 2); + + // Assert + Assert.Equal("Reply 3", messages[^1].Text); + } +}