mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7fa6a304d8 | ||
|
|
83de93f377 | ||
|
|
e296bb5078 | ||
|
|
2563dbae71 | ||
|
|
c1b1c18bc2 | ||
|
|
0e664029d1 | ||
|
|
475c065680 | ||
|
|
d6c4dbea96 | ||
|
|
42d424587d | ||
|
|
e061d095e7 |
@@ -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" />
|
||||
|
||||
+21
@@ -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
|
||||
|
||||
|
||||
+102
@@ -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
|
||||
};
|
||||
}
|
||||
+114
@@ -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);
|
||||
}
|
||||
}
|
||||
+120
@@ -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
|
||||
}
|
||||
+35
@@ -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);
|
||||
}
|
||||
+161
@@ -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++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+26
@@ -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);
|
||||
}
|
||||
+91
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+166
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+118
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+97
@@ -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>
|
||||
|
||||
+136
@@ -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());
|
||||
}
|
||||
}
|
||||
+144
@@ -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;
|
||||
}
|
||||
}
|
||||
+114
@@ -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);
|
||||
}
|
||||
}
|
||||
+44
@@ -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);
|
||||
}
|
||||
}
|
||||
+57
@@ -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);
|
||||
}
|
||||
}
|
||||
+24
@@ -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);
|
||||
}
|
||||
}
|
||||
+40
@@ -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;
|
||||
}
|
||||
}
|
||||
+399
@@ -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);
|
||||
}
|
||||
}
|
||||
+52
@@ -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();
|
||||
}
|
||||
}
|
||||
+27
@@ -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);
|
||||
}
|
||||
}
|
||||
+36
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+65
@@ -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());
|
||||
}
|
||||
}
|
||||
+135
@@ -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);
|
||||
}
|
||||
}
|
||||
+164
@@ -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);
|
||||
}
|
||||
}
|
||||
+131
@@ -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);
|
||||
}
|
||||
}
|
||||
+93
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user