Compare commits

...
Author SHA1 Message Date
Chris Rickman 7fa6a304d8 Sample format 2026-03-06 11:33:37 -08:00
ChrisandGitHub 83de93f377 Merge branch 'main' into crickman/feature-compaction 2026-03-05 11:00:52 -08:00
Chris Rickman e296bb5078 Sample readme 2026-03-05 08:35:49 -08:00
ChrisandGitHub 2563dbae71 Merge branch 'main' into crickman/feature-compaction 2026-03-05 08:10:51 -08:00
Chris Rickman c1b1c18bc2 Namespace 2026-03-05 02:29:36 -08:00
ChrisandGitHub 0e664029d1 Merge branch 'main' into crickman/feature-compaction 2026-03-05 02:27:18 -08:00
Chris Rickman 475c065680 Sample 2026-03-05 01:53:53 -08:00
Chris Rickman d6c4dbea96 Checkpoint 2026-03-02 22:00:21 -08:00
Chris Rickman 42d424587d Sample 2026-03-02 13:46:28 -08:00
Chris Rickman e061d095e7 Checkpoint 2026-03-02 13:27:25 -08:00
36 changed files with 3078 additions and 0 deletions
+1
View File
@@ -56,6 +56,7 @@
<Project Path="samples/02-agents/Agents/Agent_Step15_DeepResearch/Agent_Step15_DeepResearch.csproj" />
<Project Path="samples/02-agents/Agents/Agent_Step16_Declarative/Agent_Step16_Declarative.csproj" />
<Project Path="samples/02-agents/Agents/Agent_Step17_AdditionalAIContext/Agent_Step17_AdditionalAIContext.csproj" />
<Project Path="samples/02-agents/Agents/Agent_Step18_CompactionPipeline/Agent_Step18_CompactionPipeline.csproj" />
</Folder>
<Folder Name="/Samples/02-agents/DeclarativeAgents/">
<Project Path="samples/02-agents/DeclarativeAgents/ChatClient/DeclarativeChatClientAgents.csproj" />
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.OpenAI" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,120 @@
// 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.ForegroundColor = ConsoleColor.Cyan;
Console.WriteLine($"\n[Messages: x{history.Count}]\n");
Console.ResetColor();
}
}
// Run a multi-turn conversation with tool calls to exercise the pipeline.
string[] prompts =
[
"What's the price of a laptop?",
"How about a keyboard?",
"And a mouse?",
"Which product is the cheapest?",
"Can you compare the laptop and the keyboard for me?",
"What was the first product I asked about?",
"Thank you!",
];
foreach (string prompt in prompts)
{
Console.ForegroundColor = ConsoleColor.Cyan;
Console.Write("\n[User] ");
Console.ResetColor();
Console.WriteLine(prompt);
Console.ForegroundColor = ConsoleColor.Cyan;
Console.Write("\n[Agent] ");
Console.ResetColor();
Console.WriteLine(await agent.RunAsync(prompt, session));
PrintChatHistory();
}
@@ -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
@@ -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
{
/// <summary>
/// %%% COMMENT
/// </summary>
public enum Size
{
/// <summary>
/// %%% COMMENT
/// </summary>
Compact,
/// <summary>
/// %%% COMMENT
/// </summary>
Adequate,
/// <summary>
/// %%% COMMENT
/// </summary>
Accomodating,
}
/// <summary>
/// %%% COMMENT
/// </summary>
public enum Approach
{
/// <summary>
/// %%% COMMENT
/// </summary>
Aggressive,
/// <summary>
/// %%% COMMENT
/// </summary>
Balanced,
/// <summary>
/// %%% COMMENT
/// </summary>
Gentle,
}
/// <summary>
/// %%% COMMENT
/// </summary>
/// <param name="approach"></param>
/// <param name="size"></param>
/// <param name="chatClient"></param>
/// <returns></returns>
/// <exception cref="NotImplementedException"></exception>
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
};
}
@@ -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;
/// <summary>
/// Executes a chain of <see cref="ChatHistoryCompactionStrategy"/> instances in order
/// against a mutable message list.
/// </summary>
/// <remarks>
/// <para>
/// Each strategy's trigger is evaluated against the metrics <em>as they stand after prior strategies</em>,
/// so earlier strategies can bring the conversation within thresholds that cause later strategies to skip.
/// </para>
/// <para>
/// The pipeline is fully standalone — it can be used without any agent, session, or context provider.
/// It also implements <see cref="IChatReducer"/> so it can be used directly anywhere a reducer is
/// accepted (e.g., <see cref="InMemoryChatHistoryProviderOptions.ChatReducer"/>).
/// </para>
/// </remarks>
public partial class ChatHistoryCompactionPipeline : IChatReducer
{
private readonly ChatHistoryCompactionStrategy[] _strategies;
private readonly IChatHistoryMetricsCalculator _metricsCalculator;
/// <summary>
/// Initializes a new instance of the <see cref="ChatHistoryCompactionPipeline"/> class.
/// </summary>
/// <param name="strategies">The ordered list of compaction strategies to execute.</param>
/// <remarks>
/// By default, <see cref="DefaultChatHistoryMetricsCalculator"/> is used.
/// </remarks>
public ChatHistoryCompactionPipeline(
params IEnumerable<ChatHistoryCompactionStrategy> strategies)
: this(metricsCalculator: null, strategies) { }
/// <summary>
/// Initializes a new instance of the <see cref="ChatHistoryCompactionPipeline"/> class.
/// </summary>
/// <param name="metricsCalculator">
/// An optional metrics calculator. When <see langword="null"/>, a
/// <see cref="DefaultChatHistoryMetricsCalculator"/> is used.
/// </param>
/// <param name="strategies">The ordered list of compaction strategies to execute.</param>
public ChatHistoryCompactionPipeline(
IChatHistoryMetricsCalculator? metricsCalculator,
params IEnumerable<ChatHistoryCompactionStrategy> strategies)
{
this._strategies = [.. Throw.IfNull(strategies)];
this._metricsCalculator = metricsCalculator ?? DefaultChatHistoryMetricsCalculator.Instance;
}
/// <summary>
/// Reduces the given messages by running all strategies in sequence.
/// </summary>
/// <param name="messages">The messages to reduce.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
/// <returns>The reduced set of messages.</returns>
public virtual async Task<IEnumerable<ChatMessage>> ReduceAsync(
IEnumerable<ChatMessage> messages,
CancellationToken cancellationToken = default)
{
List<ChatMessage> messageBuffer = messages is List<ChatMessage> messageList ? messageList : [.. messages];
await this.CompactAsync(messageBuffer, cancellationToken).ConfigureAwait(false);
return messageBuffer;
}
/// <summary>
/// Run all strategies in sequence against the given messages.
/// </summary>
/// <param name="messages">The mutable message list to compact.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
/// <returns>A <see cref="CompactionPipelineResult"/> with aggregate and per-strategy metrics.</returns>
public async ValueTask<CompactionPipelineResult> CompactAsync(
List<ChatMessage> 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<CompactionResult> 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);
}
}
@@ -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;
/// <summary>
/// A named compaction strategy with an optional conditional trigger that delegates
/// actual message reduction to an <see cref="IChatReducer"/>.
/// </summary>
/// <remarks>
/// <para>
/// Each strategy wraps an <see cref="IChatReducer"/> that performs the actual compaction,
/// while the strategy adds:
/// <list type="bullet">
/// <item><description>A conditional trigger via <see cref="ShouldCompact"/> that decides whether compaction runs.</description></item>
/// <item><description>Before/after <see cref="ChatHistoryMetric"/> reporting via <see cref="CompactionResult"/>.</description></item>
/// </list>
/// </para>
/// <para>
/// For simple cases, construct a <see cref="ChatHistoryCompactionStrategy"/> directly with any
/// <see cref="IChatReducer"/>. For custom trigger logic, subclass and override <see cref="ShouldCompact"/>.
/// </para>
/// <para>
/// Reducers <b>must</b> preserve atomic message groups: an assistant message containing
/// tool calls and its corresponding tool result messages must be kept or removed together.
/// Use <see cref="DefaultChatHistoryMetricsCalculator"/> to identify these groups when authoring custom reducers.
/// </para>
/// </remarks>
public abstract class ChatHistoryCompactionStrategy
{
internal static readonly AsyncLocal<ChatHistoryMetric> s_currentMetrics = new();
/// <summary>
/// Initializes a new instance of the <see cref="ChatHistoryCompactionStrategy"/> class.
/// </summary>
/// <param name="reducer">The <see cref="IChatReducer"/> that performs the actual message compaction.</param>
protected ChatHistoryCompactionStrategy(IChatReducer reducer)
{
this.Reducer = Throw.IfNull(reducer);
}
/// <summary>
/// Exposes the current <see cref="ChatHistoryMetric"/> for the executing strategy, allowing <see cref="Reducer"/> to make informed decisions.
/// </summary>
protected static ChatHistoryMetric CurrentMetrics => s_currentMetrics.Value ?? throw new InvalidOperationException($"No active {nameof(ChatHistoryCompactionStrategy)}.");
/// <summary>
/// Gets the <see cref="IChatReducer"/> that performs the actual message compaction.
/// </summary>
public IChatReducer Reducer { get; }
/// <summary>
/// Gets the display name of this strategy, used for logging and diagnostics.
/// </summary>
/// <remarks>
/// The default implementation returns the type name of the underlying <see cref="IChatReducer"/>.
/// </remarks>
public virtual string Name => this.Reducer.GetType().Name;
/// <summary>
/// Evaluates whether this strategy should execute given the current conversation metrics.
/// </summary>
/// <param name="metrics">The current conversation metrics.</param>
/// <returns>
/// <see langword="true"/> to proceed with compaction; <see langword="false"/> to skip.
/// </returns>
protected abstract bool ShouldCompact(ChatHistoryMetric metrics);
/// <summary>
/// Execute this strategy: check the trigger, delegate to the <see cref="IChatReducer"/>, and report metrics.
/// </summary>
/// <param name="history">The mutable message list to compact.</param>
/// <param name="metricsCalculator">The calculator to use for metric snapshots.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
/// <returns>A <see cref="CompactionResult"/> reporting the outcome.</returns>
internal async ValueTask<CompactionResult> CompactAsync(
List<ChatMessage> 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<ChatMessage> 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);
}
}
@@ -0,0 +1,45 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
namespace Microsoft.Agents.AI.Compaction;
/// <summary>
/// Immutable snapshot of conversation metrics used for compaction trigger evaluation and reporting.
/// </summary>
public sealed class ChatHistoryMetric
{
/// <summary>
/// Gets the estimated token count across all messages.
/// </summary>
public int TokenCount { get; init; }
/// <summary>
/// Gets the total serialized byte count of all messages.
/// </summary>
public long ByteCount { get; init; }
#pragma warning disable IDE0001 // Simplify Names
/// <summary>
/// Gets the total number of <see cref="Microsoft.Extensions.AI.ChatMessage"/> objects.
/// </summary>
#pragma warning restore IDE0001 // Simplify Names
public int MessageCount { get; init; }
/// <summary>
/// Gets the number of tool/function call content items across all messages.
/// </summary>
public int ToolCallCount { get; init; }
/// <summary>
/// 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.
/// </summary>
public int UserTurnCount { get; init; }
/// <summary>
/// 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.
/// </summary>
public IReadOnlyList<ChatMessageGroup> Groups { get; init; } = [];
}
@@ -0,0 +1,64 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
namespace Microsoft.Agents.AI.Compaction;
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public readonly struct ChatMessageGroup : IEquatable<ChatMessageGroup>
{
/// <summary>
/// Initializes a new instance of the <see cref="ChatMessageGroup"/> struct.
/// </summary>
/// <param name="startIndex">The zero-based index of the first message in this group.</param>
/// <param name="count">The number of messages in this group.</param>
/// <param name="kind">The kind of this message group.</param>
public ChatMessageGroup(int startIndex, int count, ChatMessageGroupKind kind)
{
this.StartIndex = startIndex;
this.Count = count;
this.Kind = kind;
}
/// <summary>
/// Gets the zero-based index of the first message in this group within the original message list.
/// </summary>
public int StartIndex { get; }
/// <summary>
/// Gets the number of messages in this group.
/// </summary>
public int Count { get; }
/// <summary>
/// Gets the kind of this message group.
/// </summary>
public ChatMessageGroupKind Kind { get; }
/// <inheritdoc/>
public bool Equals(ChatMessageGroup other) =>
this.StartIndex == other.StartIndex &&
this.Count == other.Count &&
this.Kind == other.Kind;
/// <inheritdoc/>
public override bool Equals(object? obj) =>
obj is ChatMessageGroup other &&
this.Equals(other);
/// <inheritdoc/>
public override int GetHashCode() => HashCode.Combine(this.StartIndex, this.Count, (int)this.Kind);
/// <summary>Determines whether two <see cref="ChatMessageGroup"/> instances are equal.</summary>
public static bool operator ==(ChatMessageGroup left, ChatMessageGroup right) => left.Equals(right);
/// <summary>Determines whether two <see cref="ChatMessageGroup"/> instances are not equal.</summary>
public static bool operator !=(ChatMessageGroup left, ChatMessageGroup right) => !left.Equals(right);
}
@@ -0,0 +1,27 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI.Compaction;
/// <summary>
/// Identifies the kind of an atomic message group in a conversation.
/// </summary>
public enum ChatMessageGroupKind
{
/// <summary>A system message.</summary>
System,
/// <summary>A user message (start of a user turn).</summary>
UserTurn,
/// <summary>An assistant message with tool calls and their corresponding tool result messages.</summary>
AssistantToolGroup,
/// <summary>An assistant message without tool calls.</summary>
AssistantPlain,
/// <summary>A tool result message that is not part of a recognized group.</summary>
ToolResult,
/// <summary>A message with an unrecognized role.</summary>
Other
}
@@ -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;
/// <summary>
/// Represents a chat history compaction strategy that uses a condition function to determine when compaction should
/// occur.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public class ChatReducerCompactionStrategy : ChatHistoryCompactionStrategy
{
private readonly Func<ChatHistoryMetric, bool> _condition;
/// <summary>
/// Initializes a new instance of the <see cref="ChatReducerCompactionStrategy"/> class.
/// </summary>
public ChatReducerCompactionStrategy(
IChatReducer reducer,
Func<ChatHistoryMetric, bool> condition)
: base(reducer)
{
this._condition = Throw.IfNull(condition);
}
/// <inheritdoc/>
protected override bool ShouldCompact(ChatHistoryMetric metrics) => this._condition(metrics);
}
@@ -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;
/// <summary>
/// Reports the aggregate outcome of a <see cref="ChatHistoryCompactionPipeline"/> execution.
/// </summary>
public sealed class CompactionPipelineResult
{
/// <summary>
/// Initializes a new instance of the <see cref="CompactionPipelineResult"/> class.
/// </summary>
/// <param name="before">Metrics of the conversation before any strategy ran.</param>
/// <param name="after">Metrics of the conversation after all strategies ran.</param>
/// <param name="strategyResults">Per-strategy results in execution order.</param>
internal CompactionPipelineResult(
ChatHistoryMetric before,
ChatHistoryMetric after,
IReadOnlyList<CompactionResult> strategyResults)
{
this.Before = Throw.IfNull(before);
this.After = Throw.IfNull(after);
this.StrategyResults = Throw.IfNull(strategyResults);
}
/// <summary>
/// Gets the conversation metrics before any compaction strategy ran.
/// </summary>
public ChatHistoryMetric Before { get; }
/// <summary>
/// Gets the conversation metrics after all compaction strategies ran.
/// </summary>
public ChatHistoryMetric After { get; }
/// <summary>
/// Gets the per-strategy results in execution order.
/// </summary>
public IReadOnlyList<CompactionResult> StrategyResults { get; }
/// <summary>
/// Gets a value indicating whether any strategy modified the message list.
/// </summary>
public bool AnyApplied => this.StrategyResults.Any(r => r.Applied);
}
@@ -0,0 +1,55 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Compaction;
/// <summary>
/// Reports the outcome of a single <see cref="ChatHistoryCompactionStrategy"/> execution.
/// </summary>
public sealed class CompactionResult
{
/// <summary>
/// Initializes a new instance of the <see cref="CompactionResult"/> class.
/// </summary>
/// <param name="strategyName">The name of the strategy that produced this result.</param>
/// <param name="applied">Whether the strategy modified the message list.</param>
/// <param name="before">Metrics before the strategy ran.</param>
/// <param name="after">Metrics after the strategy ran.</param>
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);
}
/// <summary>
/// Gets the name of the strategy that produced this result.
/// </summary>
public string StrategyName { get; }
/// <summary>
/// Gets a value indicating whether the strategy modified the message list.
/// </summary>
public bool Applied { get; }
/// <summary>
/// Gets the conversation metrics before the strategy executed.
/// </summary>
public ChatHistoryMetric Before { get; }
/// <summary>
/// Gets the conversation metrics after the strategy executed.
/// </summary>
public ChatHistoryMetric After { get; }
/// <summary>
/// Creates a <see cref="CompactionResult"/> representing a skipped strategy.
/// </summary>
/// <param name="strategyName">The name of the skipped strategy.</param>
/// <param name="metrics">The current conversation metrics.</param>
/// <returns>A result indicating no compaction was applied.</returns>
internal static CompactionResult Skipped(string strategyName, ChatHistoryMetric metrics)
=> new(strategyName, applied: false, metrics, metrics);
}
@@ -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;
/// <summary>
/// Default implementation of <see cref="IChatHistoryMetricsCalculator"/> that uses
/// JSON serialization length heuristics for token and byte estimation.
/// </summary>
/// <remarks>
/// <para>
/// 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 <see cref="IChatHistoryMetricsCalculator"/>
/// with a model-appropriate tokenizer.
/// </para>
/// </remarks>
public sealed class DefaultChatHistoryMetricsCalculator : IChatHistoryMetricsCalculator
{
/// <summary>
/// Gets the singleton instance of the chat history metrics calculator.
/// </summary>
/// <remarks>
/// <see cref="DefaultChatHistoryMetricsCalculator"/> can be safety accessed by
/// concurrent threads.
/// </remarks>
public static readonly DefaultChatHistoryMetricsCalculator Instance = new();
private const int DefaultCharsPerToken = 4;
private const int PerMessageOverheadTokens = 4;
private readonly int _charsPerToken;
/// <summary>
/// Initializes a new instance of the <see cref="DefaultChatHistoryMetricsCalculator"/> class.
/// </summary>
/// <param name="charsPerToken">
/// The approximate number of characters per token used for estimation. Default is 4.
/// </param>
public DefaultChatHistoryMetricsCalculator(int charsPerToken = DefaultCharsPerToken)
{
this._charsPerToken = charsPerToken > 0 ? charsPerToken : DefaultCharsPerToken;
}
/// <inheritdoc/>
public ChatHistoryMetric Calculate(IReadOnlyList<ChatMessage> 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<ChatMessageGroup> 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++;
}
}
}
}
}
@@ -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.
/// <summary>
/// Computes <see cref="ChatHistoryMetric"/> for a list of messages.
/// </summary>
/// <remarks>
/// Token counting is model-specific. Implementations can provide precise tokenization
/// (e.g., using tiktoken or a model-specific tokenizer) or use estimation heuristics.
/// </remarks>
public interface IChatHistoryMetricsCalculator
{
/// <summary>
/// Compute metrics for the given messages.
/// </summary>
/// <param name="messages">The messages to analyze.</param>
/// <returns>A <see cref="ChatHistoryMetric"/> snapshot.</returns>
ChatHistoryMetric Calculate(IReadOnlyList<ChatMessage> messages);
}
@@ -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;
/// <summary>
/// A compaction strategy that keeps only the most recent user turns and their
/// associated response groups, removing older turns to bound conversation length.
/// </summary>
/// <remarks>
/// <para>
/// This strategy always preserves system messages. It identifies user turns in the
/// conversation and keeps the last <c>maxTurns</c> turns along with all response groups
/// (assistant replies, tool call groups) that follow each kept turn.
/// </para>
/// <para>
/// The trigger condition fires only when the number of user turns exceeds <c>maxTurns</c>.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
public class SlidingWindowCompactionStrategy : ChatHistoryCompactionStrategy
{
private readonly int _maxTurns;
/// <summary>
/// Initializes a new instance of the <see cref="SlidingWindowCompactionStrategy"/> class.
/// </summary>
/// <param name="maxTurns">
/// The maximum number of user turns to keep. Older turns and their associated responses are removed.
/// </param>
public SlidingWindowCompactionStrategy(int maxTurns)
: base(new SlidingWindowReducer(maxTurns))
{
this._maxTurns = maxTurns;
}
/// <inheritdoc/>
protected override bool ShouldCompact(ChatHistoryMetric metrics) =>
metrics.UserTurnCount > this._maxTurns;
/// <summary>
/// An <see cref="IChatReducer"/> that keeps system messages and the last N user turns
/// with all their associated response groups.
/// </summary>
private sealed class SlidingWindowReducer(int maxTurns) : IChatReducer
{
public Task<IEnumerable<ChatMessage>> ReduceAsync(
IEnumerable<ChatMessage> messages,
CancellationToken cancellationToken = default)
{
IReadOnlyList<ChatMessage> messageList = [.. messages]; // %%% PERFORMANCE
IReadOnlyList<ChatMessageGroup> 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<ChatMessage> 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<IEnumerable<ChatMessage>>(result);
}
}
}
@@ -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;
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// The strategy protects system messages and the most recent <c>preserveRecentGroups</c>
/// non-system groups. All older groups are collected and sent to the <see cref="IChatClient"/>
/// for summarization. The resulting summary replaces those messages as a single assistant message.
/// </para>
/// </remarks>
public class SummarizationCompactionStrategy : ChatHistoryCompactionStrategy
{
private readonly int _maxTokens;
/// <summary>
/// The default summarization prompt used when none is provided.
/// </summary>
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.
""";
/// <summary>
/// Initializes a new instance of the <see cref="SummarizationCompactionStrategy"/> class.
/// </summary>
/// <param name="chatClient">The <see cref="IChatClient"/> to use for generating summaries. A smaller, faster model is recommended.</param>
/// <param name="maxTokens">The maximum token budget. Summarization is triggered when the token count exceeds this value.</param>
/// <param name="preserveRecentGroups">
/// The number of most-recent non-system message groups to protect from summarization.
/// Defaults to 4, preserving the current and recent exchanges.
/// </param>
/// <param name="summarizationPrompt">
/// An optional custom system prompt for the summarization LLM call. When <see langword="null"/>,
/// a default prompt that emphasizes fact-preservation is used.
/// </param>
public SummarizationCompactionStrategy(
IChatClient chatClient,
int maxTokens,
int preserveRecentGroups = 4,
string? summarizationPrompt = null)
: base(new SummarizationReducer(chatClient, preserveRecentGroups, summarizationPrompt ?? DefaultSummarizationPrompt))
{
this._maxTokens = maxTokens;
}
/// <inheritdoc/>
protected override bool ShouldCompact(ChatHistoryMetric metrics) =>
metrics.TokenCount > this._maxTokens;
/// <summary>
/// An <see cref="IChatReducer"/> that sends older message groups to an LLM for summarization,
/// then replaces them with a single summary message.
/// </summary>
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<IEnumerable<ChatMessage>> ReduceAsync(
IEnumerable<ChatMessage> messages,
CancellationToken cancellationToken = default)
{
IReadOnlyList<ChatMessage> messageList = [.. messages];
IReadOnlyList<ChatMessageGroup> groups = CurrentMetrics.Groups;
List<ChatMessageGroup> 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<ChatMessage> 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<ChatMessage> 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<ChatMessage> 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;
}
}
}
@@ -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;
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// <para>
/// This is the gentlest compaction strategy — it does not remove any user messages or
/// plain assistant responses. It only targets <see cref="ChatMessageGroupKind.AssistantToolGroup"/>
/// entries outside the protected recent window, replacing each multi-message group
/// (assistant call + tool results) with a single assistant message like
/// <c>[Tool calls: get_weather, search_docs]</c>.
/// </para>
/// <para>
/// The trigger condition fires only when token count exceeds <c>maxTokens</c> and
/// there is at least one tool call in the conversation.
/// </para>
/// </remarks>
public class ToolResultCompactionStrategy : ChatHistoryCompactionStrategy
{
/// <summary>
/// The default value for `preserveRecentGroups` used when constructing <see cref="ToolResultCompactionStrategy"/>.
/// </summary>
public const int DefaultPreserveRecentGroups = 2;
private readonly int _maxTokens;
/// <summary>
/// Initializes a new instance of the <see cref="ToolResultCompactionStrategy"/> class.
/// </summary>
/// <param name="maxTokens">The maximum token budget. Tool groups are collapsed when the token count exceeds this value.</param>
/// <param name="preserveRecentGroups">
/// 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.
/// </param>
public ToolResultCompactionStrategy(int maxTokens, int preserveRecentGroups = DefaultPreserveRecentGroups)
: base(new ToolResultClearingReducer(preserveRecentGroups))
{
this._maxTokens = maxTokens;
}
/// <inheritdoc/>
protected override bool ShouldCompact(ChatHistoryMetric metrics) =>
metrics.TokenCount > this._maxTokens && metrics.ToolCallCount > 0;
/// <summary>
/// An <see cref="IChatReducer"/> that collapses <see cref="ChatMessageGroupKind.AssistantToolGroup"/>
/// entries into single summary messages, preserving the most recent groups.
/// </summary>
private sealed class ToolResultClearingReducer(int preserveRecentGroups) : IChatReducer
{
public Task<IEnumerable<ChatMessage>> ReduceAsync(
IEnumerable<ChatMessage> messages,
CancellationToken cancellationToken = default)
{
IReadOnlyList<ChatMessage> messageList = [.. messages];
IReadOnlyList<ChatMessageGroup> groups = CurrentMetrics.Groups;
List<ChatMessageGroup> nonSystemGroups = [.. groups.Where(g => g.Kind != ChatMessageGroupKind.System)];
int protectedFromIndex = Math.Max(0, nonSystemGroups.Count - preserveRecentGroups);
HashSet<int> protectedGroupStarts = [];
for (int i = protectedFromIndex; i < nonSystemGroups.Count; i++)
{
protectedGroupStarts.Add(nonSystemGroups[i].StartIndex);
}
List<ChatMessage> 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<string> 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<IEnumerable<ChatMessage>>(anyCollapsed ? result : messageList);
}
}
}
@@ -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;
/// <summary>
/// A compaction strategy that removes the oldest message groups until the estimated
/// token count is within a specified budget.
/// </summary>
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// The trigger condition fires only when the current token count exceeds <c>maxTokens</c>.
/// </para>
/// </remarks>
public class TruncationCompactionStrategy : ChatHistoryCompactionStrategy
{
private readonly int _maxTokens;
/// <summary>
/// Initializes a new instance of the <see cref="TruncationCompactionStrategy"/> class.
/// </summary>
/// <param name="maxTokens">The maximum token budget. Groups are removed until the token count is at or below this value.</param>
/// <param name="preserveRecentGroups">
/// 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.
/// </param>
public TruncationCompactionStrategy(int maxTokens, int preserveRecentGroups = 1)
: base(new TruncationReducer(preserveRecentGroups))
{
this._maxTokens = maxTokens;
}
/// <inheritdoc/>
protected override bool ShouldCompact(ChatHistoryMetric metrics) =>
metrics.TokenCount > this._maxTokens;
/// <summary>
/// An <see cref="IChatReducer"/> that removes the oldest non-system message groups,
/// keeping at least the most recent group.
/// </summary>
private sealed class TruncationReducer(int preserveRecentGroups) : IChatReducer
{
public Task<IEnumerable<ChatMessage>> ReduceAsync(
IEnumerable<ChatMessage> messages,
CancellationToken cancellationToken = default)
{
IReadOnlyList<ChatMessage> messageList = [.. messages];
ChatMessageGroup[] removableGroups = [.. CurrentMetrics.Groups.Where(g => g.Kind != ChatMessageGroupKind.System)];
if (removableGroups.Length == 0)
{
return Task.FromResult<IEnumerable<ChatMessage>>(messageList);
}
// Remove oldest non-system groups, keeping at least preserveRecentGroups.
int maxRemovable = removableGroups.Length - preserveRecentGroups;
if (maxRemovable <= 0)
{
return Task.FromResult<IEnumerable<ChatMessage>>(messageList);
}
HashSet<int> removedGroupStarts = [];
for (int ri = 0; ri < maxRemovable; ri++)
{
removedGroupStarts.Add(removableGroups[ri].StartIndex);
}
List<ChatMessage> 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<IEnumerable<ChatMessage>>(messagesToKeep);
}
}
}
@@ -31,6 +31,10 @@
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFrameworkIdentifier)' != '.NETCoreApp'">
<PackageReference Include="Microsoft.Bcl.HashCode" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="Microsoft.Agents.AI.Abstractions.UnitTests" />
</ItemGroup>
@@ -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<ChatMessage> 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<ChatMessage> 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<ChatMessage> 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<IChatHistoryMetricsCalculator> calcMock = new();
calcMock
.Setup(c => c.Calculate(Moq.It.IsAny<IReadOnlyList<ChatMessage>>()))
.Returns(new ChatHistoryMetric { MessageCount = 42 });
ChatHistoryCompactionPipeline pipeline = new(calcMock.Object, []);
List<ChatMessage> 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<IReadOnlyList<ChatMessage>>()), Moq.Times.Once);
}
[Fact]
public async Task ReduceAsync_DelegatesCompactionAsync()
{
// Arrange
ChatHistoryCompactionPipeline pipeline = new([new RemoveFirstMessageStrategy()]);
List<ChatMessage> messages =
[
new(ChatRole.User, "First"),
new(ChatRole.User, "Second"),
new(ChatRole.User, "Third"),
];
// Act
IEnumerable<ChatMessage> result = await pipeline.ReduceAsync(messages, default);
List<ChatMessage> 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<ChatMessage> result = await pipeline.ReduceAsync(messages, default);
// Assert
Assert.Equal(2, result.Count());
}
}
@@ -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<ChatMessage> 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<ChatMessage> 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<ChatMessage> messages =
[
new(ChatRole.User, "First"),
new(ChatRole.User, "Second"),
];
Mock<IChatReducer> reducerMock = new();
reducerMock
.Setup(r => r.ReduceAsync(It.IsAny<IEnumerable<ChatMessage>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((IEnumerable<ChatMessage> 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<IEnumerable<ChatMessage>>(), It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task ReducerNoChange_ReturnsFalseAsync()
{
// Arrange
List<ChatMessage> messages =
[
new(ChatRole.User, "Hello"),
];
Mock<IChatReducer> reducerMock = new();
reducerMock
.Setup(r => r.ReduceAsync(It.IsAny<IEnumerable<ChatMessage>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((IEnumerable<ChatMessage> 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<IChatReducer> 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<InvalidOperationException>(() => TestCompactionStrategy.GetCurrentMetrics());
}
public static async ValueTask<CompactionResult> RunCompactionStrategyAsync(ChatHistoryCompactionStrategy strategy, List<ChatMessage> 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;
}
}
@@ -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<ChatMessage> messages = [new(ChatRole.User, "Hello")];
Mock<IChatReducer> reducerMock = new();
ChatReducerCompactionStrategy strategy = new(reducerMock.Object, _ => false);
// Act & Assert
await RunCompactionStrategySkippedAsync(strategy, messages);
// Assert
reducerMock.Verify(
r => r.ReduceAsync(It.IsAny<IEnumerable<ChatMessage>>(), It.IsAny<CancellationToken>()),
Times.Never);
}
[Fact]
public async Task ConditionTrue_RunsReducerAsync()
{
// Arrange
List<ChatMessage> messages =
[
new(ChatRole.User, "First"),
new(ChatRole.User, "Second"),
];
Mock<IChatReducer> reducerMock = new();
reducerMock
.Setup(r => r.ReduceAsync(It.IsAny<IEnumerable<ChatMessage>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((IEnumerable<ChatMessage> 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<IEnumerable<ChatMessage>>(), It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task ConditionReceivesMetricsAsync()
{
// Arrange
List<ChatMessage> messages =
[
new(ChatRole.User, "Hello"),
new(ChatRole.Assistant, "Hi"),
];
ChatHistoryMetric? capturedMetrics = null;
Mock<IChatReducer> reducerMock = new();
reducerMock
.Setup(r => r.ReduceAsync(It.IsAny<IEnumerable<ChatMessage>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((IEnumerable<ChatMessage> 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<ChatMessage> messages = [new(ChatRole.User, "Hello")];
Mock<IChatReducer> reducerMock = new();
reducerMock
.Setup(r => r.ReduceAsync(It.IsAny<IEnumerable<ChatMessage>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((IEnumerable<ChatMessage> msgs, CancellationToken _) => msgs);
ChatReducerCompactionStrategy strategy = new(reducerMock.Object, _ => true);
// Act & Assert
await RunCompactionStrategySkippedAsync(strategy, messages);
}
[Fact]
public void Name_ReturnsReducerTypeName()
{
// Arrange
Mock<IChatReducer> reducerMock = new();
// Act
ChatReducerCompactionStrategy strategy = new(reducerMock.Object, _ => true);
// Assert
Assert.Equal(reducerMock.Object.GetType().Name, strategy.Name);
}
}
@@ -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);
}
}
@@ -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<CompactionResult> 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);
}
}
@@ -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);
}
}
@@ -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<CompactionResult> RunCompactionStrategyReducedAsync(ChatHistoryCompactionStrategy strategy, List<ChatMessage> 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<CompactionResult> RunCompactionStrategySkippedAsync(ChatHistoryCompactionStrategy strategy, List<ChatMessage> 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;
}
}
@@ -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<ChatMessage> 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<ChatMessage> 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<string, object?> { ["city"] = "NYC" }),
new FunctionCallContent("call2", "get_time"),
]);
List<ChatMessage> 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<ChatMessage> 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<ChatMessage> 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<ChatMessage> 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<ChatMessage> 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<ChatMessage> 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<ChatMessage> 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<ChatMessage> 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<ChatMessage> messages =
[
new(ChatRole.System, "You are a helpful assistant"),
];
// Act
IReadOnlyList<ChatMessageGroup> 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<string, object?> { ["city"] = "NYC" }),
]);
ChatMessage toolResult = new(ChatRole.Tool, [
new FunctionResultContent("call1", "Sunny, 72°F"),
]);
List<ChatMessage> messages =
[
new(ChatRole.User, "What's the weather?"),
assistantMsg,
toolResult,
];
// Act
IReadOnlyList<ChatMessageGroup> 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<ChatMessage> messages = [assistantMsg, tool1, tool2];
// Act
IReadOnlyList<ChatMessageGroup> 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<ChatMessage> 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<ChatMessageGroup> 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<ChatMessage> messages =
[
new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c1", "orphan result")]),
];
// Act
IReadOnlyList<ChatMessageGroup> 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<ChatMessage> messages =
[
new(new ChatRole("custom"), "custom message"),
];
// Act
IReadOnlyList<ChatMessageGroup> 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<ChatMessage> messages = [msg];
// Act
IReadOnlyList<ChatMessageGroup> groups = calculator.Calculate(messages).Groups;
// Assert
Assert.Single(groups);
Assert.Equal(ChatMessageGroupKind.AssistantPlain, groups[0].Kind);
}
}
@@ -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;
/// <summary>
/// Provides a way to set <see cref="AIAgent.CurrentRunContext"/> in unit tests
/// so that the underlying <c>AsyncLocal</c> is populated for code that reads it.
/// </summary>
internal static class AgentRunContextHarness
{
private static readonly ContextAgentShim s_instance = new();
/// <summary>
/// Sets <see cref="AIAgent.CurrentRunContext"/> and invokes the provided action.
/// </summary>
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<AgentSession> CreateSessionCoreAsync(CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
protected override ValueTask<JsonElement> SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
protected override Task<AgentResponse> RunCoreAsync(IEnumerable<ChatMessage> messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
protected override IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(IEnumerable<ChatMessage> messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
}
}
@@ -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<IEnumerable<ChatMessage>> ReduceAsync(IEnumerable<ChatMessage> messages, CancellationToken cancellationToken = default)
=> Task.FromResult(messages);
}
}
@@ -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<IEnumerable<ChatMessage>> ReduceAsync(IEnumerable<ChatMessage> messages, CancellationToken cancellationToken = default)
{
List<ChatMessage> list = messages.ToList();
if (list.Count > 1)
{
list.RemoveAt(0);
}
return Task.FromResult<IEnumerable<ChatMessage>>(list);
}
}
}
@@ -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());
}
}
@@ -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<ChatMessage> 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<ChatMessage> 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<ChatMessage> 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<ChatMessage> 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<ChatMessage> 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<ChatMessage> 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);
}
}
@@ -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<ChatMessage> messages =
[
new(ChatRole.User, "Hello"),
new(ChatRole.Assistant, "Hi"),
];
Mock<IChatClient> chatClientMock = new();
SummarizationCompactionStrategy strategy = new(chatClientMock.Object, maxTokens: 100000);
// Act & Assert
await RunCompactionStrategySkippedAsync(strategy, messages);
// Assert
chatClientMock.Verify(
c => c.GetResponseAsync(It.IsAny<IEnumerable<ChatMessage>>(), It.IsAny<ChatOptions>(), It.IsAny<CancellationToken>()),
Times.Never);
}
[Fact]
public async Task SummarizesOldGroupsAsync()
{
// Arrange
List<ChatMessage> 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<IChatClient> chatClientMock = new();
chatClientMock
.Setup(c => c.GetResponseAsync(It.IsAny<IEnumerable<ChatMessage>>(), It.IsAny<ChatOptions>(), It.IsAny<CancellationToken>()))
.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<ChatMessage> 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<IChatClient> chatClientMock = new();
chatClientMock
.Setup(c => c.GetResponseAsync(It.IsAny<IEnumerable<ChatMessage>>(), It.IsAny<ChatOptions>(), It.IsAny<CancellationToken>()))
.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<ChatMessage> messages =
[
new(ChatRole.User, "Hello"),
new(ChatRole.Assistant, "Hi"),
];
Mock<IChatClient> 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<IEnumerable<ChatMessage>>(), It.IsAny<ChatOptions>(), It.IsAny<CancellationToken>()),
Times.Never);
}
[Fact]
public async Task CustomPrompt_UsedInRequestAsync()
{
// Arrange
const string CustomPrompt = "Summarize briefly.";
List<ChatMessage> messages =
[
new(ChatRole.User, "First"),
new(ChatRole.Assistant, "Reply"),
new(ChatRole.User, "Second"),
new(ChatRole.Assistant, "Reply 2"),
];
List<ChatMessage>? capturedMessages = null;
Mock<IChatClient> chatClientMock = new();
chatClientMock
.Setup(c => c.GetResponseAsync(It.IsAny<IEnumerable<ChatMessage>>(), It.IsAny<ChatOptions>(), It.IsAny<CancellationToken>()))
.Callback<IEnumerable<ChatMessage>, 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<ChatMessage> messages =
[
new(ChatRole.User, "First"),
new(ChatRole.Assistant, "Reply"),
new(ChatRole.User, "Second"),
new(ChatRole.Assistant, "Reply 2"),
];
Mock<IChatClient> chatClientMock = new();
chatClientMock
.Setup(c => c.GetResponseAsync(It.IsAny<IEnumerable<ChatMessage>>(), It.IsAny<ChatOptions>(), It.IsAny<CancellationToken>()))
.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);
}
}
@@ -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<ChatMessage> 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<ChatMessage> 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<ChatMessage> 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<ChatMessage> 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<ChatMessage> 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<ChatMessage> messages =
[
new(ChatRole.User, "Hello"),
new(ChatRole.Assistant, "Hi there"),
];
ToolResultCompactionStrategy strategy = new(maxTokens: 1);
// Act & Assert
await RunCompactionStrategySkippedAsync(strategy, messages);
}
}
@@ -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<ChatMessage> messages =
[
new(ChatRole.User, "Hello"),
];
TruncationCompactionStrategy strategy = new(maxTokens: 100000);
// Act & Assert
await RunCompactionStrategySkippedAsync(strategy, messages);
}
[Fact]
public async Task OverLimit_RemovesOldestGroupsAsync()
{
// Arrange
List<ChatMessage> 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<ChatMessage> 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<ChatMessage> 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<ChatMessage> 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);
}
}