mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d3a7a4da87 | ||
|
|
43d226f8ca | ||
|
|
1a8a58f753 | ||
|
|
c513694b2f | ||
|
|
f42863e354 | ||
|
|
06f55c0494 | ||
|
|
7e2c5ad4e6 | ||
|
|
6ce0447ff6 | ||
|
|
defb9dd51c | ||
|
|
f70423bd99 | ||
|
|
1428286bd1 | ||
|
|
7608005dd7 | ||
|
|
dda15ea4b4 | ||
|
|
b275d3410a | ||
|
|
0e8b9b283f | ||
|
|
eb8406214e | ||
|
|
fcd60daed5 | ||
|
|
23cf75be3c |
@@ -108,6 +108,7 @@
|
||||
<!-- Inference SDKs -->
|
||||
<PackageVersion Include="AWSSDK.Extensions.Bedrock.MEAI" Version="4.0.5.1" />
|
||||
<PackageVersion Include="Microsoft.ML.OnnxRuntimeGenAI" Version="0.10.0" />
|
||||
<PackageVersion Include="Microsoft.ML.Tokenizers" Version="2.0.0" />
|
||||
<PackageVersion Include="OllamaSharp" Version="5.4.8" />
|
||||
<PackageVersion Include="OpenAI" Version="2.8.0" />
|
||||
<!-- Identity -->
|
||||
|
||||
@@ -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,104 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample demonstrates how to use a ChatHistoryCompactionPipeline as the ChatReducer for an agent's
|
||||
// in-memory chat history. The pipeline chains multiple compaction strategies from gentle to aggressive:
|
||||
// 1. ToolResultCompactionStrategy - Collapses old tool-call groups into concise summaries
|
||||
// 2. SummarizationCompactionStrategy - LLM-compresses older conversation spans
|
||||
// 3. SlidingWindowCompactionStrategy - Keeps only the most recent N user turns
|
||||
// 4. TruncationCompactionStrategy - Emergency token-budget backstop
|
||||
|
||||
using System.ComponentModel;
|
||||
using Azure.AI.OpenAI;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Compaction;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
|
||||
var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
|
||||
|
||||
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
|
||||
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
|
||||
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
|
||||
AzureOpenAIClient openAIClient = new(new Uri(endpoint), new DefaultAzureCredential());
|
||||
|
||||
// Create a chat client for the agent and a separate one for the summarization strategy.
|
||||
// Using the same model for simplicity; in production, use a smaller/cheaper model for summarization.
|
||||
IChatClient agentChatClient = openAIClient.GetChatClient(deploymentName).AsIChatClient();
|
||||
IChatClient summarizerChatClient = openAIClient.GetChatClient(deploymentName).AsIChatClient();
|
||||
|
||||
// Define a tool the agent can use, so we can see tool-result compaction in action.
|
||||
[Description("Look up the current price of a product by name.")]
|
||||
static string LookupPrice([Description("The product name to look up.")] string productName) =>
|
||||
productName.ToUpperInvariant() switch
|
||||
{
|
||||
"LAPTOP" => "The laptop costs $999.99.",
|
||||
"KEYBOARD" => "The keyboard costs $79.99.",
|
||||
"MOUSE" => "The mouse costs $29.99.",
|
||||
_ => $"Sorry, I don't have pricing for '{productName}'."
|
||||
};
|
||||
|
||||
// Configure the compaction pipeline with one of each strategy, ordered least to most aggressive.
|
||||
PipelineCompactionStrategy compactionPipeline =
|
||||
new(// 1. Gentle: collapse old tool-call groups into short summaries like "[Tool calls: LookupPrice]"
|
||||
new ToolResultCompactionStrategy(CompactionTriggers.TokensExceed(0x200)),
|
||||
|
||||
// 2. Moderate: use an LLM to summarize older conversation spans into a concise message
|
||||
new SummarizationCompactionStrategy(summarizerChatClient, CompactionTriggers.TokensExceed(0x500)),
|
||||
|
||||
// 3. Aggressive: keep only the last N user turns and their responses
|
||||
new SlidingWindowCompactionStrategy(CompactionTriggers.TurnsExceed(4)),
|
||||
|
||||
// 4. Emergency: drop oldest groups until under the token budget
|
||||
new TruncationCompactionStrategy(CompactionTriggers.TokensExceed(0x8000)));
|
||||
|
||||
// Create the agent with an in-memory chat history provider whose reducer is the compaction pipeline.
|
||||
AIAgent agent =
|
||||
agentChatClient.AsAIAgent(
|
||||
new ChatClientAgentOptions
|
||||
{
|
||||
Name = "ShoppingAssistant",
|
||||
ChatOptions = new()
|
||||
{
|
||||
Instructions =
|
||||
"""
|
||||
You are a helpful, but long winded, shopping assistant.
|
||||
Help the user look up prices and compare products.
|
||||
When responding, Be sure to be extra descriptive and use as
|
||||
many words as possible without sounding ridiculous.
|
||||
""",
|
||||
Tools = [AIFunctionFactory.Create(LookupPrice)],
|
||||
},
|
||||
CompactionStrategy = compactionPipeline,
|
||||
});
|
||||
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
|
||||
// Helper to print chat history size
|
||||
void PrintChatHistory()
|
||||
{
|
||||
if (session.TryGetInMemoryChatHistory(out var history))
|
||||
{
|
||||
Console.WriteLine($" [Chat history: {history.Count} messages]\n");
|
||||
}
|
||||
}
|
||||
|
||||
// Run a multi-turn conversation with tool calls to exercise the pipeline.
|
||||
string[] prompts =
|
||||
[
|
||||
"What's the price of a laptop?",
|
||||
"How about a keyboard?",
|
||||
"And a mouse?",
|
||||
"Which product is the cheapest?",
|
||||
"Can you compare the laptop and the keyboard for me?",
|
||||
"What was the first product I asked about?",
|
||||
"Thank you!",
|
||||
];
|
||||
|
||||
foreach (string prompt in prompts)
|
||||
{
|
||||
Console.WriteLine($"User: {prompt}");
|
||||
Console.WriteLine($"Agent: {await agent.RunAsync(prompt, session)}");
|
||||
|
||||
PrintChatHistory();
|
||||
}
|
||||
@@ -79,20 +79,21 @@ public sealed class InMemoryChatHistoryProvider : ChatHistoryProvider
|
||||
/// <exception cref="ArgumentNullException"><paramref name="messages"/> is <see langword="null"/>.</exception>
|
||||
public void SetMessages(AgentSession? session, List<ChatMessage> messages)
|
||||
{
|
||||
_ = Throw.IfNull(messages);
|
||||
Throw.IfNull(messages);
|
||||
|
||||
var state = this._sessionState.GetOrInitializeState(session);
|
||||
State state = this._sessionState.GetOrInitializeState(session);
|
||||
state.Messages = messages;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async ValueTask<IEnumerable<ChatMessage>> ProvideChatHistoryAsync(InvokingContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var state = this._sessionState.GetOrInitializeState(context.Session);
|
||||
State state = this._sessionState.GetOrInitializeState(context.Session);
|
||||
|
||||
if (this.ReducerTriggerEvent is InMemoryChatHistoryProviderOptions.ChatReducerTriggerEvent.BeforeMessagesRetrieval && this.ChatReducer is not null)
|
||||
{
|
||||
state.Messages = (await this.ChatReducer.ReduceAsync(state.Messages, cancellationToken).ConfigureAwait(false)).ToList();
|
||||
// Apply pre-invocation compaction strategy if configured
|
||||
await this.CompactMessagesAsync(state, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return state.Messages;
|
||||
@@ -101,18 +102,31 @@ public sealed class InMemoryChatHistoryProvider : ChatHistoryProvider
|
||||
/// <inheritdoc />
|
||||
protected override async ValueTask StoreChatHistoryAsync(InvokedContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var state = this._sessionState.GetOrInitializeState(context.Session);
|
||||
State state = this._sessionState.GetOrInitializeState(context.Session);
|
||||
|
||||
// Add request and response messages to the provider
|
||||
var allNewMessages = context.RequestMessages.Concat(context.ResponseMessages ?? []);
|
||||
state.Messages.AddRange(allNewMessages);
|
||||
|
||||
if (this.ReducerTriggerEvent is InMemoryChatHistoryProviderOptions.ChatReducerTriggerEvent.AfterMessageAdded && this.ChatReducer is not null)
|
||||
if (this.ReducerTriggerEvent is InMemoryChatHistoryProviderOptions.ChatReducerTriggerEvent.AfterMessageAdded)
|
||||
{
|
||||
state.Messages = (await this.ChatReducer.ReduceAsync(state.Messages, cancellationToken).ConfigureAwait(false)).ToList();
|
||||
// Apply pre-write compaction strategy if configured
|
||||
await this.CompactMessagesAsync(state, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CompactMessagesAsync(State state, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (this.ChatReducer is not null)
|
||||
{
|
||||
// ChatReducer takes precedence, if configured
|
||||
state.Messages = [.. await this.ChatReducer.ReduceAsync(state.Messages, cancellationToken).ConfigureAwait(false)];
|
||||
return;
|
||||
}
|
||||
|
||||
// %%% TODO: CONSIDER COMPACTION
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents the state of a <see cref="InMemoryChatHistoryProvider"/> stored in the <see cref="AgentSession.StateBag"/>.
|
||||
/// </summary>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.Agents.AI.Compaction;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
@@ -45,6 +46,22 @@ public sealed class ChatClientAgentOptions
|
||||
/// </summary>
|
||||
public IEnumerable<AIContextProvider>? AIContextProviders { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the <see cref="CompactionStrategy"/> to use for in-run context compaction.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// When set, this strategy is applied to the message list before each call to the underlying
|
||||
/// <see cref="IChatClient"/> during agent execution. This keeps the context within token limits
|
||||
/// as tool calls accumulate during long-running agent invocations.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The strategy organizes messages into atomic groups (preserving tool-call/result pairings)
|
||||
/// before applying compaction logic. See <see cref="CompactionStrategy"/> for details.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public CompactionStrategy? CompactionStrategy { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether to use the provided <see cref="IChatClient"/> instance as is,
|
||||
/// without applying any default decorators.
|
||||
@@ -101,6 +118,7 @@ public sealed class ChatClientAgentOptions
|
||||
ChatOptions = this.ChatOptions?.Clone(),
|
||||
ChatHistoryProvider = this.ChatHistoryProvider,
|
||||
AIContextProviders = this.AIContextProviders is null ? null : new List<AIContextProvider>(this.AIContextProviders),
|
||||
CompactionStrategy = this.CompactionStrategy,
|
||||
UseProvidedChatClientAsIs = this.UseProvidedChatClientAsIs,
|
||||
ClearOnChatHistoryProviderConflict = this.ClearOnChatHistoryProviderConflict,
|
||||
WarnOnChatHistoryProviderConflict = this.WarnOnChatHistoryProviderConflict,
|
||||
|
||||
@@ -4,6 +4,7 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Compaction;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -53,9 +54,16 @@ public static class ChatClientExtensions
|
||||
{
|
||||
var chatBuilder = chatClient.AsBuilder();
|
||||
|
||||
// Add compaction as the innermost middleware so it runs before every LLM call,
|
||||
// including those triggered by tool call iterations within FunctionInvokingChatClient.
|
||||
if (options?.CompactionStrategy is { } compactionStrategy)
|
||||
{
|
||||
chatBuilder.Use(innerClient => new CompactingChatClient(innerClient, compactionStrategy));
|
||||
}
|
||||
|
||||
if (chatClient.GetService<FunctionInvokingChatClient>() is null)
|
||||
{
|
||||
_ = chatBuilder.Use((innerClient, services) =>
|
||||
chatBuilder.Use((innerClient, services) =>
|
||||
{
|
||||
var loggerFactory = services.GetService<ILoggerFactory>();
|
||||
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// A delegating <see cref="IChatClient"/> that applies an <see cref="CompactionStrategy"/> to the message list
|
||||
/// before each call to the inner chat client.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This client is used for in-run compaction during the tool loop. It is inserted into the
|
||||
/// <see cref="IChatClient"/> pipeline before the `FunctionInvokingChatClient` so that
|
||||
/// compaction is applied before every LLM call, including those triggered by tool call iterations.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The compaction strategy organizes messages into atomic groups (preserving tool-call/result pairings)
|
||||
/// before applying compaction logic. Only included messages are forwarded to the inner client.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class CompactingChatClient : DelegatingChatClient
|
||||
{
|
||||
private readonly CompactionStrategy _compactionStrategy;
|
||||
private readonly ProviderSessionState<State> _sessionState;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="CompactingChatClient"/> class.
|
||||
/// </summary>
|
||||
/// <param name="innerClient">The inner chat client to delegate to.</param>
|
||||
/// <param name="compactionStrategy">The compaction strategy to apply before each call.</param>
|
||||
public CompactingChatClient(IChatClient innerClient, CompactionStrategy compactionStrategy)
|
||||
: base(innerClient)
|
||||
{
|
||||
this._compactionStrategy = Throw.IfNull(compactionStrategy);
|
||||
this._sessionState = new ProviderSessionState<State>(
|
||||
_ => new State(),
|
||||
Convert.ToBase64String(BitConverter.GetBytes(compactionStrategy.GetHashCode())),
|
||||
AgentJsonUtilities.DefaultOptions);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task<ChatResponse> GetResponseAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
ChatOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
IEnumerable<ChatMessage> compactedMessages = await this.ApplyCompactionAsync(messages, cancellationToken).ConfigureAwait(false);
|
||||
return await base.GetResponseAsync(compactedMessages, options, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
ChatOptions? options = null,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
IEnumerable<ChatMessage> compactedMessages = await this.ApplyCompactionAsync(messages, cancellationToken).ConfigureAwait(false);
|
||||
await foreach (ChatResponseUpdate update in base.GetStreamingResponseAsync(compactedMessages, options, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
yield return update;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override object? GetService(Type serviceType, object? serviceKey = null)
|
||||
{
|
||||
Throw.IfNull(serviceType);
|
||||
|
||||
return
|
||||
serviceKey is null && serviceType.IsInstanceOfType(typeof(CompactionStrategy)) ?
|
||||
this._compactionStrategy :
|
||||
base.GetService(serviceType, serviceKey);
|
||||
}
|
||||
|
||||
private async Task<IEnumerable<ChatMessage>> ApplyCompactionAsync(
|
||||
IEnumerable<ChatMessage> messages, CancellationToken cancellationToken)
|
||||
{
|
||||
List<ChatMessage> messageList = messages as List<ChatMessage> ?? [.. messages]; // %%% TODO - LIST COPY
|
||||
|
||||
AgentRunContext? currentAgentContext = AIAgent.CurrentRunContext;
|
||||
if (currentAgentContext is null ||
|
||||
currentAgentContext.Session is null)
|
||||
{
|
||||
// No session available — no reason to compact
|
||||
return messages;
|
||||
}
|
||||
|
||||
State state = this._sessionState.GetOrInitializeState(currentAgentContext.Session);
|
||||
|
||||
MessageIndex messageIndex;
|
||||
if (state.MessageIndex.Count > 0)
|
||||
{
|
||||
// Update existing index
|
||||
messageIndex = new(state.MessageIndex);
|
||||
messageIndex.Update(messageList);
|
||||
}
|
||||
else
|
||||
{
|
||||
// First pass — initialize message index state
|
||||
messageIndex = MessageIndex.Create(messageList);
|
||||
}
|
||||
|
||||
// Apply compaction
|
||||
Stopwatch stopwatch = Stopwatch.StartNew();
|
||||
bool wasCompacted = await this._compactionStrategy.CompactAsync(messageIndex, cancellationToken).ConfigureAwait(false);
|
||||
stopwatch.Stop();
|
||||
|
||||
Debug.WriteLine($"COMPACTION: {wasCompacted} - {stopwatch.ElapsedMilliseconds}ms");
|
||||
|
||||
if (wasCompacted)
|
||||
{
|
||||
state.MessageIndex = [.. messageIndex.Groups]; // %%% TODO - LIST COPY
|
||||
}
|
||||
|
||||
return wasCompacted ? messageIndex.GetIncludedMessages() : messageList;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents the state of a <see cref="InMemoryChatHistoryProvider"/> stored in the <see cref="AgentSession.StateBag"/>.
|
||||
/// </summary>
|
||||
public sealed class State
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the message index.
|
||||
/// </summary>
|
||||
[JsonPropertyName("messages")]
|
||||
public List<MessageGroup> MessageIndex { get; set; } = [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// Base class for strategies that compact a <see cref="MessageIndex"/> to reduce context size.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Compaction strategies operate on <see cref="MessageIndex"/> instances, which organize messages
|
||||
/// into atomic groups that respect the tool-call/result pairing constraint. Strategies mutate the collection
|
||||
/// in place by marking groups as excluded, removing groups, or replacing message content (e.g., with summaries).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Every strategy requires a <see cref="CompactionTrigger"/> that determines whether compaction should
|
||||
/// proceed based on current <see cref="MessageIndex"/> metrics (token count, message count, turn count, etc.).
|
||||
/// The base class evaluates this trigger at the start of <see cref="CompactAsync"/> and skips compaction when
|
||||
/// the trigger returns <see langword="false"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// An optional <b>target</b> condition controls when compaction stops. Strategies incrementally exclude
|
||||
/// groups and re-evaluate the target after each exclusion, stopping as soon as the target returns
|
||||
/// <see langword="true"/>. When no target is specified, it defaults to the inverse of the trigger —
|
||||
/// meaning compaction stops when the trigger condition would no longer fire.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Strategies can be applied at three lifecycle points:
|
||||
/// <list type="bullet">
|
||||
/// <item><description><b>In-run</b>: During the tool loop, before each LLM call, to keep context within token limits.</description></item>
|
||||
/// <item><description><b>Pre-write</b>: Before persisting messages to storage via <see cref="ChatHistoryProvider"/>.</description></item>
|
||||
/// <item><description><b>On existing storage</b>: As a maintenance operation to compact stored history.</description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Multiple strategies can be composed by applying them sequentially to the same <see cref="MessageIndex"/>
|
||||
/// via <see cref="PipelineCompactionStrategy"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public abstract class CompactionStrategy
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="CompactionStrategy"/> class.
|
||||
/// </summary>
|
||||
/// <param name="trigger">
|
||||
/// The <see cref="CompactionTrigger"/> that determines whether compaction should proceed.
|
||||
/// </param>
|
||||
/// <param name="target">
|
||||
/// An optional target condition that controls when compaction stops. Strategies re-evaluate
|
||||
/// this predicate after each incremental exclusion and stop when it returns <see langword="true"/>.
|
||||
/// When <see langword="null"/>, defaults to the inverse of the <paramref name="trigger"/> — compaction
|
||||
/// stops as soon as the trigger condition would no longer fire.
|
||||
/// </param>
|
||||
protected CompactionStrategy(CompactionTrigger trigger, CompactionTrigger? target = null)
|
||||
{
|
||||
this.Trigger = Throw.IfNull(trigger);
|
||||
this.Target = target ?? (index => !trigger(index));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the trigger predicate that controls when compaction proceeds.
|
||||
/// </summary>
|
||||
protected CompactionTrigger Trigger { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the target predicate that controls when compaction stops.
|
||||
/// Strategies re-evaluate this after each incremental exclusion and stop when it returns <see langword="true"/>.
|
||||
/// </summary>
|
||||
protected CompactionTrigger Target { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates the <see cref="Trigger"/> and, when it fires, delegates to
|
||||
/// <see cref="ApplyCompactionAsync"/> and reports compaction metrics.
|
||||
/// </summary>
|
||||
/// <param name="index">The message index to compact. The strategy mutates this collection in place.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
|
||||
/// <returns>A task representing the asynchronous operation. The task result is <see langword="true"/> if compaction occurred, <see langword="false"/> otherwise.</returns>
|
||||
public async Task<bool> CompactAsync(MessageIndex index, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!this.Trigger(index))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
int beforeTokens = index.IncludedTokenCount;
|
||||
int beforeGroups = index.IncludedGroupCount;
|
||||
int beforeMessages = index.IncludedMessageCount;
|
||||
|
||||
Stopwatch stopwatch = Stopwatch.StartNew();
|
||||
|
||||
bool compacted = await this.ApplyCompactionAsync(index, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
stopwatch.Stop();
|
||||
|
||||
if (compacted)
|
||||
{
|
||||
Debug.WriteLine(
|
||||
$"""
|
||||
COMPACTION: {this.GetType().Name}
|
||||
Duration {stopwatch.ElapsedMilliseconds}ms
|
||||
Messages {beforeMessages} => {index.IncludedMessageCount}
|
||||
Groups {beforeGroups} => {index.IncludedGroupCount}
|
||||
Tokens {beforeTokens} => {index.IncludedTokenCount}
|
||||
""");
|
||||
}
|
||||
|
||||
return compacted;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies the strategy-specific compaction logic to the specified message index.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This method is called by <see cref="CompactAsync"/> only when the <see cref="Trigger"/>
|
||||
/// returns <see langword="true"/>. Implementations do not need to evaluate the trigger or
|
||||
/// report metrics — the base class handles both. Implementations should use <see cref="Target"/>
|
||||
/// to determine when to stop compacting incrementally.
|
||||
/// </remarks>
|
||||
/// <param name="index">The message index to compact. The strategy mutates this collection in place.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
|
||||
/// <returns>A task whose result is <see langword="true"/> if any compaction was performed, <see langword="false"/> otherwise.</returns>
|
||||
protected abstract Task<bool> ApplyCompactionAsync(MessageIndex index, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Microsoft.Agents.AI.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// A predicate that evaluates whether compaction should proceed based on current <see cref="MessageIndex"/> metrics.
|
||||
/// </summary>
|
||||
/// <param name="index">The current message index with group, token, message, and turn metrics.</param>
|
||||
/// <returns><see langword="true"/> if compaction should proceed; <see langword="false"/> to skip.</returns>
|
||||
public delegate bool CompactionTrigger(MessageIndex index);
|
||||
@@ -0,0 +1,114 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Linq;
|
||||
|
||||
namespace Microsoft.Agents.AI.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// Provides factory methods for common <see cref="CompactionTrigger"/> predicates.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// These triggers evaluate included (non-excluded) metrics from the <see cref="MessageIndex"/>.
|
||||
/// Combine triggers with <see cref="All"/> or <see cref="Any"/> for compound conditions,
|
||||
/// or write a custom lambda for full flexibility.
|
||||
/// </remarks>
|
||||
public static class CompactionTriggers
|
||||
{
|
||||
/// <summary>
|
||||
/// Always trigger compaction, regardless of the message index state.
|
||||
/// </summary>
|
||||
public static readonly CompactionTrigger Always =
|
||||
_ => true;
|
||||
|
||||
/// <summary>
|
||||
/// Always trigger compaction, regardless of the message index state.
|
||||
/// </summary>
|
||||
public static readonly CompactionTrigger Never =
|
||||
_ => false;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a trigger that fires when the included token count is below the specified maximum.
|
||||
/// </summary>
|
||||
/// <param name="maxTokens">The token threshold. Compaction proceeds when included tokens exceed this value.</param>
|
||||
/// <returns>A <see cref="CompactionTrigger"/> that evaluates included token count.</returns>
|
||||
public static CompactionTrigger TokensBelow(int maxTokens) =>
|
||||
index => index.IncludedTokenCount < maxTokens;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a trigger that fires when the included token count exceeds the specified maximum.
|
||||
/// </summary>
|
||||
/// <param name="maxTokens">The token threshold. Compaction proceeds when included tokens exceed this value.</param>
|
||||
/// <returns>A <see cref="CompactionTrigger"/> that evaluates included token count.</returns>
|
||||
public static CompactionTrigger TokensExceed(int maxTokens) =>
|
||||
index => index.IncludedTokenCount > maxTokens;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a trigger that fires when the included message count exceeds the specified maximum.
|
||||
/// </summary>
|
||||
/// <param name="maxMessages">The message threshold. Compaction proceeds when included messages exceed this value.</param>
|
||||
/// <returns>A <see cref="CompactionTrigger"/> that evaluates included message count.</returns>
|
||||
public static CompactionTrigger MessagesExceed(int maxMessages) =>
|
||||
index => index.IncludedMessageCount > maxMessages;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a trigger that fires when the included user turn count exceeds the specified maximum.
|
||||
/// </summary>
|
||||
/// <param name="maxTurns">The turn threshold. Compaction proceeds when included turns exceed this value.</param>
|
||||
/// <returns>A <see cref="CompactionTrigger"/> that evaluates included turn count.</returns>
|
||||
public static CompactionTrigger TurnsExceed(int maxTurns) =>
|
||||
index => index.IncludedTurnCount > maxTurns;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a trigger that fires when the included group count exceeds the specified maximum.
|
||||
/// </summary>
|
||||
/// <param name="maxGroups">The group threshold. Compaction proceeds when included groups exceed this value.</param>
|
||||
/// <returns>A <see cref="CompactionTrigger"/> that evaluates included group count.</returns>
|
||||
public static CompactionTrigger GroupsExceed(int maxGroups) =>
|
||||
index => index.IncludedGroupCount > maxGroups;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a trigger that fires when the included message index contains at least one
|
||||
/// non-excluded <see cref="MessageGroupKind.ToolCall"/> group.
|
||||
/// </summary>
|
||||
/// <returns>A <see cref="CompactionTrigger"/> that evaluates included tool call presence.</returns>
|
||||
public static CompactionTrigger HasToolCalls() =>
|
||||
index => index.Groups.Any(g => !g.IsExcluded && g.Kind == MessageGroupKind.ToolCall);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a compound trigger that fires only when <b>all</b> of the specified triggers fire.
|
||||
/// </summary>
|
||||
/// <param name="triggers">The triggers to combine with logical AND.</param>
|
||||
/// <returns>A <see cref="CompactionTrigger"/> that requires all conditions to be met.</returns>
|
||||
public static CompactionTrigger All(params CompactionTrigger[] triggers) =>
|
||||
index =>
|
||||
{
|
||||
for (int i = 0; i < triggers.Length; i++)
|
||||
{
|
||||
if (!triggers[i](index))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates a compound trigger that fires when <b>any</b> of the specified triggers fire.
|
||||
/// </summary>
|
||||
/// <param name="triggers">The triggers to combine with logical OR.</param>
|
||||
/// <returns>A <see cref="CompactionTrigger"/> that requires at least one condition to be met.</returns>
|
||||
public static CompactionTrigger Any(params CompactionTrigger[] triggers) =>
|
||||
index =>
|
||||
{
|
||||
for (int i = 0; i < triggers.Length; i++)
|
||||
{
|
||||
if (triggers[i](index))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a logical group of <see cref="ChatMessage"/> instances that must be kept or removed together during compaction.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Message groups ensure atomic preservation of related messages. For example, an assistant message
|
||||
/// containing tool calls and its corresponding tool result messages form a <see cref="MessageGroupKind.ToolCall"/>
|
||||
/// group — removing one without the other would cause LLM API errors.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Groups also support exclusion semantics: a group can be marked as excluded (with an optional reason)
|
||||
/// to indicate it should not be included in the messages sent to the model, while still being preserved
|
||||
/// for diagnostics, storage, or later re-inclusion.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Each group tracks its <see cref="MessageCount"/>, <see cref="ByteCount"/>, and <see cref="TokenCount"/>
|
||||
/// so that <see cref="MessageIndex"/> can efficiently aggregate totals across all or only included groups.
|
||||
/// These values are computed by <see cref="MessageIndex.Create"/> and passed into the constructor.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class MessageGroup
|
||||
{
|
||||
/// <summary>
|
||||
/// The <see cref="ChatMessage.AdditionalProperties"/> key used to identify a message as a compaction summary.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When this key is present with a value of <see langword="true"/>, the message is classified as
|
||||
/// <see cref="MessageGroupKind.Summary"/> by <see cref="MessageIndex.Create"/>.
|
||||
/// </remarks>
|
||||
public static readonly string SummaryPropertyKey = "_is_summary";
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="MessageGroup"/> class.
|
||||
/// </summary>
|
||||
/// <param name="kind">The kind of message group.</param>
|
||||
/// <param name="messages">The messages in this group. The list is captured as a read-only snapshot.</param>
|
||||
/// <param name="byteCount">The total UTF-8 byte count of the text content in the messages.</param>
|
||||
/// <param name="tokenCount">The token count for the messages, computed by a tokenizer or estimated.</param>
|
||||
/// <param name="turnIndex">
|
||||
/// The zero-based user turn this group belongs to, or <see langword="null"/> for groups that precede
|
||||
/// the first user message (e.g., system messages).
|
||||
/// </param>
|
||||
[JsonConstructor]
|
||||
public MessageGroup(MessageGroupKind kind, IReadOnlyList<ChatMessage> messages, int byteCount, int tokenCount, int? turnIndex = null)
|
||||
{
|
||||
this.Kind = kind;
|
||||
this.Messages = messages;
|
||||
this.MessageCount = messages.Count;
|
||||
this.ByteCount = byteCount;
|
||||
this.TokenCount = tokenCount;
|
||||
this.TurnIndex = turnIndex;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the kind of this message group.
|
||||
/// </summary>
|
||||
public MessageGroupKind Kind { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the messages in this group.
|
||||
/// </summary>
|
||||
public IReadOnlyList<ChatMessage> Messages { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of messages in this group.
|
||||
/// </summary>
|
||||
public int MessageCount { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total UTF-8 byte count of the text content in this group's messages.
|
||||
/// </summary>
|
||||
public int ByteCount { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the estimated or actual token count for this group's messages.
|
||||
/// </summary>
|
||||
public int TokenCount { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the zero-based user turn index this group belongs to, or <see langword="null"/>
|
||||
/// for groups that precede the first user message (e.g., system messages).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A turn starts with a <see cref="MessageGroupKind.User"/> group and includes all subsequent
|
||||
/// non-user, non-system groups until the next user group or end of conversation.
|
||||
/// </remarks>
|
||||
public int? TurnIndex { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether this group is excluded from the projected message list.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Excluded groups are preserved in the collection for diagnostics or storage purposes
|
||||
/// but are not included when calling <see cref="MessageIndex.GetIncludedMessages"/>.
|
||||
/// </remarks>
|
||||
public bool IsExcluded { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets an optional reason explaining why this group was excluded.
|
||||
/// </summary>
|
||||
public string? ExcludeReason { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Microsoft.Agents.AI.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// Identifies the kind of a <see cref="MessageGroup"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Message groups are used to classify logically related messages that must be kept together
|
||||
/// during compaction operations. For example, an assistant message containing tool calls
|
||||
/// and its corresponding tool result messages form an atomic <see cref="ToolCall"/> group.
|
||||
/// </remarks>
|
||||
public enum MessageGroupKind
|
||||
{
|
||||
/// <summary>
|
||||
/// A system message group containing one or more system messages.
|
||||
/// </summary>
|
||||
System,
|
||||
|
||||
/// <summary>
|
||||
/// A user message group containing a single user message.
|
||||
/// </summary>
|
||||
User,
|
||||
|
||||
/// <summary>
|
||||
/// An assistant message group containing a single assistant text response (no tool calls).
|
||||
/// </summary>
|
||||
AssistantText,
|
||||
|
||||
/// <summary>
|
||||
/// An atomic tool call group containing an assistant message with tool calls
|
||||
/// followed by the corresponding tool result messages.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This group must be treated as an atomic unit during compaction. Removing the assistant
|
||||
/// message without its tool results (or vice versa) will cause LLM API errors.
|
||||
/// </remarks>
|
||||
ToolCall,
|
||||
|
||||
#pragma warning disable IDE0001 // Simplify Names
|
||||
/// <summary>
|
||||
/// A summary message group produced by a compaction strategy (e.g., <c>SummarizationCompactionStrategy</c>).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Summary groups replace previously compacted messages with a condensed representation.
|
||||
/// They are identified by the <see cref="MessageGroup.SummaryPropertyKey"/> metadata entry
|
||||
/// on the underlying <see cref="Microsoft.Extensions.AI.ChatMessage"/>.
|
||||
/// </remarks>
|
||||
#pragma warning restore IDE0001 // Simplify Names
|
||||
Summary,
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.ML.Tokenizers;
|
||||
|
||||
namespace Microsoft.Agents.AI.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a collection of <see cref="MessageGroup"/> instances derived from a flat list of <see cref="ChatMessage"/> objects.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <see cref="MessageIndex"/> provides structural grouping of messages into logical units that
|
||||
/// respect the atomic group preservation constraint: tool call assistant messages and their corresponding
|
||||
/// tool result messages are always grouped together.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This collection supports exclusion-based projection, where groups can be marked as excluded
|
||||
/// without being removed, allowing compaction strategies to toggle visibility while preserving
|
||||
/// the full history for diagnostics or storage.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Each group tracks its own <see cref="MessageGroup.MessageCount"/>, <see cref="MessageGroup.ByteCount"/>,
|
||||
/// and <see cref="MessageGroup.TokenCount"/>. The collection provides aggregate properties for both
|
||||
/// the total (all groups) and included (non-excluded groups only) counts.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Instances created via <see cref="Create"/> track internal state that enables efficient incremental
|
||||
/// updates via <see cref="Update"/>. This allows caching a <see cref="MessageIndex"/> instance and
|
||||
/// appending only new messages without reprocessing the entire history.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class MessageIndex
|
||||
{
|
||||
private int _currentTurn;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the list of message groups in this collection.
|
||||
/// </summary>
|
||||
public IList<MessageGroup> Groups { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the tokenizer used for computing token counts, or <see langword="null"/> if token counts are estimated.
|
||||
/// </summary>
|
||||
public Tokenizer? Tokenizer { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of raw messages that have been processed into groups.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This value is set by <see cref="Create"/> and updated by <see cref="Update"/>.
|
||||
/// It is used by <see cref="Update"/> to determine which messages are new and need processing.
|
||||
/// </remarks>
|
||||
public int ProcessedMessageCount { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="MessageIndex"/> class with the specified groups.
|
||||
/// </summary>
|
||||
/// <param name="groups">The message groups.</param>
|
||||
/// <param name="tokenizer">An optional tokenizer retained for computing token counts when adding new groups.</param>
|
||||
public MessageIndex(IList<MessageGroup> groups, Tokenizer? tokenizer = null)
|
||||
{
|
||||
this.Groups = groups;
|
||||
this.Tokenizer = tokenizer;
|
||||
|
||||
// Restore turn counter from the last group that has a TurnIndex
|
||||
for (int index = groups.Count - 1; index >= 0; --index)
|
||||
{
|
||||
if (this.Groups[index].TurnIndex.HasValue)
|
||||
{
|
||||
this._currentTurn = this.Groups[index].TurnIndex!.Value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a <see cref="MessageIndex"/> from a flat list of <see cref="ChatMessage"/> instances.
|
||||
/// </summary>
|
||||
/// <param name="messages">The messages to group.</param>
|
||||
/// <param name="tokenizer">
|
||||
/// An optional <see cref="Tokenizer"/> for computing token counts on each group.
|
||||
/// When <see langword="null"/>, token counts are estimated as <c>ByteCount / 4</c>.
|
||||
/// </param>
|
||||
/// <returns>A new <see cref="MessageIndex"/> with messages organized into logical groups.</returns>
|
||||
/// <remarks>
|
||||
/// The grouping algorithm:
|
||||
/// <list type="bullet">
|
||||
/// <item><description>System messages become <see cref="MessageGroupKind.System"/> groups.</description></item>
|
||||
/// <item><description>User messages become <see cref="MessageGroupKind.User"/> groups.</description></item>
|
||||
/// <item><description>Assistant messages with tool calls, followed by their corresponding tool result messages, become <see cref="MessageGroupKind.ToolCall"/> groups.</description></item>
|
||||
/// <item><description>Assistant messages marked with <see cref="MessageGroup.SummaryPropertyKey"/> become <see cref="MessageGroupKind.Summary"/> groups.</description></item>
|
||||
/// <item><description>Assistant messages without tool calls become <see cref="MessageGroupKind.AssistantText"/> groups.</description></item>
|
||||
/// </list>
|
||||
/// </remarks>
|
||||
public static MessageIndex Create(IList<ChatMessage> messages, Tokenizer? tokenizer = null)
|
||||
{
|
||||
Debug.WriteLine("COMPACTION: Creating index x{messages.Count} messages");
|
||||
MessageIndex instance = new([], tokenizer);
|
||||
instance.AppendFromMessages(messages, 0);
|
||||
return instance;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Incrementally updates the groups with new messages from the conversation.
|
||||
/// </summary>
|
||||
/// <param name="allMessages">
|
||||
/// The full list of messages for the conversation. This must be the same list (or a replacement with the same
|
||||
/// prefix) that was used to create or last update this instance.
|
||||
/// </param>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// If the message count exceeds <see cref="ProcessedMessageCount"/>, only the new (delta) messages
|
||||
/// are processed and appended as new groups. Existing groups and their compaction state (exclusions)
|
||||
/// are preserved, allowing compaction strategies to build on previous results.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// If the message count is less than <see cref="ProcessedMessageCount"/> (e.g., after storage compaction
|
||||
/// replaced messages with summaries), all groups are cleared and rebuilt from scratch.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// If the message count equals <see cref="ProcessedMessageCount"/>, no work is performed.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public void Update(IList<ChatMessage> allMessages)
|
||||
{
|
||||
if (allMessages.Count == this.ProcessedMessageCount)
|
||||
{
|
||||
return; // No new messages
|
||||
}
|
||||
|
||||
if (allMessages.Count < this.ProcessedMessageCount)
|
||||
{
|
||||
// Message list shrank (e.g., after storage compaction). Rebuild from scratch.
|
||||
this.ProcessedMessageCount = 0;
|
||||
}
|
||||
|
||||
if (this.ProcessedMessageCount == 0)
|
||||
{
|
||||
// First update on a manually constructed instance — clear any pre-existing groups
|
||||
this.Groups.Clear();
|
||||
this._currentTurn = 0;
|
||||
}
|
||||
|
||||
// Process only the delta messages
|
||||
this.AppendFromMessages(allMessages, this.ProcessedMessageCount);
|
||||
}
|
||||
|
||||
private void AppendFromMessages(IList<ChatMessage> messages, int startIndex)
|
||||
{
|
||||
int index = startIndex;
|
||||
|
||||
while (index < messages.Count)
|
||||
{
|
||||
ChatMessage message = messages[index];
|
||||
|
||||
if (message.Role == ChatRole.System)
|
||||
{
|
||||
// System messages are not part of any turn
|
||||
this.Groups.Add(CreateGroup(MessageGroupKind.System, [message], this.Tokenizer, turnIndex: null));
|
||||
index++;
|
||||
}
|
||||
else if (message.Role == ChatRole.User)
|
||||
{
|
||||
this._currentTurn++;
|
||||
this.Groups.Add(CreateGroup(MessageGroupKind.User, [message], this.Tokenizer, this._currentTurn));
|
||||
index++;
|
||||
}
|
||||
else if (message.Role == ChatRole.Assistant && HasToolCalls(message))
|
||||
{
|
||||
List<ChatMessage> groupMessages = [message];
|
||||
index++;
|
||||
|
||||
// Collect all subsequent tool result messages
|
||||
while (index < messages.Count && messages[index].Role == ChatRole.Tool)
|
||||
{
|
||||
groupMessages.Add(messages[index]);
|
||||
index++;
|
||||
}
|
||||
|
||||
this.Groups.Add(CreateGroup(MessageGroupKind.ToolCall, groupMessages, this.Tokenizer, this._currentTurn));
|
||||
}
|
||||
else if (message.Role == ChatRole.Assistant && IsSummaryMessage(message))
|
||||
{
|
||||
this.Groups.Add(CreateGroup(MessageGroupKind.Summary, [message], this.Tokenizer, this._currentTurn));
|
||||
index++;
|
||||
}
|
||||
else
|
||||
{
|
||||
this.Groups.Add(CreateGroup(MessageGroupKind.AssistantText, [message], this.Tokenizer, this._currentTurn));
|
||||
index++;
|
||||
}
|
||||
}
|
||||
|
||||
this.ProcessedMessageCount = messages.Count;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new <see cref="MessageGroup"/> with byte and token counts computed using this collection's
|
||||
/// <see cref="Tokenizer"/>, and adds it to the <see cref="Groups"/> list at the specified index.
|
||||
/// </summary>
|
||||
/// <param name="index">The zero-based index at which the group should be inserted.</param>
|
||||
/// <param name="kind">The kind of message group.</param>
|
||||
/// <param name="messages">The messages in the group.</param>
|
||||
/// <param name="turnIndex">The optional turn index to assign to the new group.</param>
|
||||
/// <returns>The newly created <see cref="MessageGroup"/>.</returns>
|
||||
public MessageGroup InsertGroup(int index, MessageGroupKind kind, IReadOnlyList<ChatMessage> messages, int? turnIndex = null)
|
||||
{
|
||||
MessageGroup group = CreateGroup(kind, messages, this.Tokenizer, turnIndex);
|
||||
this.Groups.Insert(index, group);
|
||||
return group;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new <see cref="MessageGroup"/> with byte and token counts computed using this collection's
|
||||
/// <see cref="Tokenizer"/>, and appends it to the end of the <see cref="Groups"/> list.
|
||||
/// </summary>
|
||||
/// <param name="kind">The kind of message group.</param>
|
||||
/// <param name="messages">The messages in the group.</param>
|
||||
/// <param name="turnIndex">The optional turn index to assign to the new group.</param>
|
||||
/// <returns>The newly created <see cref="MessageGroup"/>.</returns>
|
||||
public MessageGroup AddGroup(MessageGroupKind kind, IReadOnlyList<ChatMessage> messages, int? turnIndex = null)
|
||||
{
|
||||
MessageGroup group = CreateGroup(kind, messages, this.Tokenizer, turnIndex);
|
||||
this.Groups.Add(group);
|
||||
return group;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns only the messages from groups that are not excluded.
|
||||
/// </summary>
|
||||
/// <returns>A list of <see cref="ChatMessage"/> instances from included groups, in order.</returns>
|
||||
public IEnumerable<ChatMessage> GetIncludedMessages() =>
|
||||
this.Groups.Where(group => !group.IsExcluded).SelectMany(group => group.Messages);
|
||||
|
||||
/// <summary>
|
||||
/// Returns all messages from all groups, including excluded ones.
|
||||
/// </summary>
|
||||
/// <returns>A list of all <see cref="ChatMessage"/> instances, in order.</returns>
|
||||
public IEnumerable<ChatMessage> GetAllMessages() => this.Groups.SelectMany(group => group.Messages);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total number of groups, including excluded ones.
|
||||
/// </summary>
|
||||
public int TotalGroupCount => this.Groups.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total number of messages across all groups, including excluded ones.
|
||||
/// </summary>
|
||||
public int TotalMessageCount => this.Groups.Sum(g => g.MessageCount);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total UTF-8 byte count across all groups, including excluded ones.
|
||||
/// </summary>
|
||||
public int TotalByteCount => this.Groups.Sum(g => g.ByteCount);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total token count across all groups, including excluded ones.
|
||||
/// </summary>
|
||||
public int TotalTokenCount => this.Groups.Sum(g => g.TokenCount);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total number of groups that are not excluded.
|
||||
/// </summary>
|
||||
public int IncludedGroupCount => this.Groups.Count(g => !g.IsExcluded);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total number of messages across all included (non-excluded) groups.
|
||||
/// </summary>
|
||||
public int IncludedMessageCount => this.Groups.Where(g => !g.IsExcluded).Sum(g => g.MessageCount);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total UTF-8 byte count across all included (non-excluded) groups.
|
||||
/// </summary>
|
||||
public int IncludedByteCount => this.Groups.Where(g => !g.IsExcluded).Sum(g => g.ByteCount);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total token count across all included (non-excluded) groups.
|
||||
/// </summary>
|
||||
public int IncludedTokenCount => this.Groups.Where(g => !g.IsExcluded).Sum(g => g.TokenCount);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total number of user turns across all groups (including those with excluded groups).
|
||||
/// </summary>
|
||||
public int TotalTurnCount => this.Groups.Select(group => group.TurnIndex).Distinct().Count(turnIndex => turnIndex is not null);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of user turns that have at least one non-excluded group.
|
||||
/// </summary>
|
||||
public int IncludedTurnCount => this.Groups.Where(group => !group.IsExcluded).Select(group => group.TurnIndex).Distinct().Count(turnIndex => turnIndex is not null);
|
||||
|
||||
/// <summary>
|
||||
/// Returns all groups that belong to the specified user turn.
|
||||
/// </summary>
|
||||
/// <param name="turnIndex">The zero-based turn index.</param>
|
||||
/// <returns>The groups belonging to the turn, in order.</returns>
|
||||
public IEnumerable<MessageGroup> GetTurnGroups(int turnIndex) =>
|
||||
this.Groups.Where(g => g.TurnIndex == turnIndex);
|
||||
|
||||
/// <summary>
|
||||
/// Computes the UTF-8 byte count for a set of messages.
|
||||
/// </summary>
|
||||
/// <param name="messages">The messages to compute byte count for.</param>
|
||||
/// <returns>The total UTF-8 byte count of all message text content.</returns>
|
||||
public static int ComputeByteCount(IReadOnlyList<ChatMessage> messages)
|
||||
{
|
||||
int total = 0;
|
||||
for (int i = 0; i < messages.Count; i++)
|
||||
{
|
||||
string text = messages[i].Text ?? string.Empty;
|
||||
if (text.Length > 0)
|
||||
{
|
||||
total += Encoding.UTF8.GetByteCount(text);
|
||||
}
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the token count for a set of messages using the specified tokenizer.
|
||||
/// </summary>
|
||||
/// <param name="messages">The messages to compute token count for.</param>
|
||||
/// <param name="tokenizer">The tokenizer to use for counting tokens.</param>
|
||||
/// <returns>The total token count across all message text content.</returns>
|
||||
public static int ComputeTokenCount(IReadOnlyList<ChatMessage> messages, Tokenizer tokenizer)
|
||||
{
|
||||
int total = 0;
|
||||
for (int i = 0; i < messages.Count; i++)
|
||||
{
|
||||
string text = messages[i].Text ?? string.Empty;
|
||||
if (text.Length > 0)
|
||||
{
|
||||
total += tokenizer.CountTokens(text);
|
||||
}
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
|
||||
private static MessageGroup CreateGroup(MessageGroupKind kind, IReadOnlyList<ChatMessage> messages, Tokenizer? tokenizer, int? turnIndex)
|
||||
{
|
||||
int byteCount = ComputeByteCount(messages);
|
||||
int tokenCount = tokenizer is not null
|
||||
? ComputeTokenCount(messages, tokenizer)
|
||||
: byteCount / 4;
|
||||
|
||||
return new MessageGroup(kind, messages, byteCount, tokenCount, turnIndex);
|
||||
}
|
||||
|
||||
private static bool HasToolCalls(ChatMessage message)
|
||||
{
|
||||
foreach (AIContent content in message.Contents)
|
||||
{
|
||||
if (content is FunctionCallContent)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool IsSummaryMessage(ChatMessage message)
|
||||
{
|
||||
return message.AdditionalProperties?.TryGetValue(MessageGroup.SummaryPropertyKey, out object? value) is true
|
||||
&& value is true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// A compaction strategy that executes a sequential pipeline of <see cref="CompactionStrategy"/> instances
|
||||
/// against the same <see cref="MessageIndex"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Each strategy in the pipeline operates on the result of the previous one, enabling composed behaviors
|
||||
/// such as summarizing older messages first and then truncating to fit a token budget.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The pipeline's own <see cref="CompactionStrategy.Trigger"/> is evaluated first. If it returns
|
||||
/// <see langword="false"/>, none of the child strategies are executed. Each child strategy also
|
||||
/// evaluates its own trigger independently.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class PipelineCompactionStrategy : CompactionStrategy
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="PipelineCompactionStrategy"/> class.
|
||||
/// </summary>
|
||||
/// <param name="strategies">The ordered sequence of strategies to execute.</param>
|
||||
public PipelineCompactionStrategy(params IEnumerable<CompactionStrategy> strategies)
|
||||
: base(CompactionTriggers.Always)
|
||||
{
|
||||
this.Strategies = [.. Throw.IfNull(strategies)];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the ordered list of strategies in this pipeline.
|
||||
/// </summary>
|
||||
public IReadOnlyList<CompactionStrategy> Strategies { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override async Task<bool> ApplyCompactionAsync(MessageIndex index, CancellationToken cancellationToken)
|
||||
{
|
||||
bool anyCompacted = false;
|
||||
|
||||
foreach (CompactionStrategy strategy in this.Strategies)
|
||||
{
|
||||
bool compacted = await strategy.CompactAsync(index, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (compacted)
|
||||
{
|
||||
anyCompacted = true;
|
||||
}
|
||||
}
|
||||
|
||||
return anyCompacted;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Microsoft.Agents.AI.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// A compaction strategy that removes the oldest user turns and their associated response groups
|
||||
/// to bound conversation length.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This strategy always preserves system messages. It identifies user turns in the
|
||||
/// conversation (via <see cref="MessageGroup.TurnIndex"/>) and excludes the oldest turns
|
||||
/// one at a time until the <see cref="CompactionStrategy.Target"/> condition is met.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <see cref="MinimumPreserved"/> is a hard floor: even if the <see cref="CompactionStrategy.Target"/>
|
||||
/// has not been reached, compaction will not touch the last <see cref="MinimumPreserved"/> non-system groups.
|
||||
/// </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 sealed class SlidingWindowCompactionStrategy : CompactionStrategy
|
||||
{
|
||||
/// <summary>
|
||||
/// The default minimum number of most-recent non-system groups to preserve.
|
||||
/// </summary>
|
||||
public const int DefaultMinimumPreserved = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SlidingWindowCompactionStrategy"/> class.
|
||||
/// </summary>
|
||||
/// <param name="trigger">
|
||||
/// The <see cref="CompactionTrigger"/> that controls when compaction proceeds.
|
||||
/// Use <see cref="CompactionTriggers.TurnsExceed"/> for turn-based thresholds.
|
||||
/// </param>
|
||||
/// <param name="minimumPreserved">
|
||||
/// The minimum number of most-recent non-system message groups to preserve.
|
||||
/// This is a hard floor — compaction will not exclude groups beyond this limit,
|
||||
/// regardless of the target condition.
|
||||
/// </param>
|
||||
/// <param name="target">
|
||||
/// An optional target condition that controls when compaction stops. When <see langword="null"/>,
|
||||
/// defaults to the inverse of the <paramref name="trigger"/> — compaction stops as soon as the trigger would no longer fire.
|
||||
/// </param>
|
||||
public SlidingWindowCompactionStrategy(CompactionTrigger trigger, int minimumPreserved = DefaultMinimumPreserved, CompactionTrigger? target = null)
|
||||
: base(trigger, target)
|
||||
{
|
||||
this.MinimumPreserved = minimumPreserved;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the minimum number of most-recent non-system groups that are always preserved.
|
||||
/// This is a hard floor that compaction cannot exceed, regardless of the target condition.
|
||||
/// </summary>
|
||||
public int MinimumPreserved { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override Task<bool> ApplyCompactionAsync(MessageIndex index, CancellationToken cancellationToken)
|
||||
{
|
||||
// Identify protected groups: the N most-recent non-system, non-excluded groups
|
||||
List<int> nonSystemIncludedIndices = [];
|
||||
foreach (MessageGroup group in index.Groups)
|
||||
{
|
||||
if (!group.IsExcluded && group.Kind != MessageGroupKind.System)
|
||||
{
|
||||
nonSystemIncludedIndices.Add(index.Groups.IndexOf(group));
|
||||
}
|
||||
}
|
||||
|
||||
int protectedStart = Math.Max(0, nonSystemIncludedIndices.Count - this.MinimumPreserved);
|
||||
HashSet<int> protectedGroupIndices = [];
|
||||
for (int i = protectedStart; i < nonSystemIncludedIndices.Count; i++)
|
||||
{
|
||||
protectedGroupIndices.Add(nonSystemIncludedIndices[i]);
|
||||
}
|
||||
|
||||
// Collect distinct included turn indices in order (oldest first), excluding protected groups
|
||||
List<int> excludableTurns = [];
|
||||
for (int i = 0; i < index.Groups.Count; i++)
|
||||
{
|
||||
MessageGroup group = index.Groups[i];
|
||||
if (!group.IsExcluded
|
||||
&& group.Kind != MessageGroupKind.System
|
||||
&& !protectedGroupIndices.Contains(i)
|
||||
&& group.TurnIndex is int turnIndex
|
||||
&& !excludableTurns.Contains(turnIndex))
|
||||
{
|
||||
excludableTurns.Add(turnIndex);
|
||||
}
|
||||
}
|
||||
|
||||
// Exclude one turn at a time from oldest, re-checking target after each
|
||||
bool compacted = false;
|
||||
|
||||
for (int t = 0; t < excludableTurns.Count; t++)
|
||||
{
|
||||
int turnToExclude = excludableTurns[t];
|
||||
|
||||
for (int i = 0; i < index.Groups.Count; i++)
|
||||
{
|
||||
MessageGroup group = index.Groups[i];
|
||||
if (!group.IsExcluded
|
||||
&& group.Kind != MessageGroupKind.System
|
||||
&& !protectedGroupIndices.Contains(i)
|
||||
&& group.TurnIndex == turnToExclude)
|
||||
{
|
||||
group.IsExcluded = true;
|
||||
group.ExcludeReason = $"Excluded by {nameof(SlidingWindowCompactionStrategy)}";
|
||||
}
|
||||
}
|
||||
|
||||
compacted = true;
|
||||
|
||||
// Stop when target condition is met
|
||||
if (this.Target(index))
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult(compacted);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
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 single summary message that preserves key facts and context.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This strategy protects system messages and the most recent <see cref="MinimumPreserved"/>
|
||||
/// 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
|
||||
/// with <see cref="MessageGroupKind.Summary"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <see cref="MinimumPreserved"/> is a hard floor: even if the <see cref="CompactionStrategy.Target"/>
|
||||
/// has not been reached, compaction will not touch the last <see cref="MinimumPreserved"/> non-system groups.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The <see cref="CompactionTrigger"/> predicate controls when compaction proceeds.
|
||||
/// When <see langword="null"/>, the strategy compacts whenever there are groups older than the preserve window.
|
||||
/// Use <see cref="CompactionTriggers"/> for common trigger conditions such as token thresholds.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class SummarizationCompactionStrategy : CompactionStrategy
|
||||
{
|
||||
/// <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="trigger">
|
||||
/// The <see cref="CompactionTrigger"/> that controls when compaction proceeds.
|
||||
/// </param>
|
||||
/// <param name="minimumPreserved">
|
||||
/// The minimum number of most-recent non-system message groups to preserve.
|
||||
/// This is a hard floor — compaction will not summarize groups beyond this limit,
|
||||
/// regardless of the target condition. Defaults to 4, preserving the current and recent exchanges.
|
||||
/// </param>
|
||||
/// <param name="summarizationPrompt">
|
||||
/// An optional custom system prompt for the summarization LLM call. When <see langword="null"/>,
|
||||
/// <see cref="DefaultSummarizationPrompt"/> is used.
|
||||
/// </param>
|
||||
/// <param name="target">
|
||||
/// An optional target condition that controls when compaction stops. When <see langword="null"/>,
|
||||
/// defaults to the inverse of the <paramref name="trigger"/> — compaction stops as soon as the trigger would no longer fire.
|
||||
/// </param>
|
||||
public SummarizationCompactionStrategy(
|
||||
IChatClient chatClient,
|
||||
CompactionTrigger trigger,
|
||||
int minimumPreserved = 4,
|
||||
string? summarizationPrompt = null,
|
||||
CompactionTrigger? target = null)
|
||||
: base(trigger, target)
|
||||
{
|
||||
this.ChatClient = Throw.IfNull(chatClient);
|
||||
this.MinimumPreserved = minimumPreserved;
|
||||
this.SummarizationPrompt = summarizationPrompt ?? DefaultSummarizationPrompt;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the chat client used for generating summaries.
|
||||
/// </summary>
|
||||
public IChatClient ChatClient { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the minimum number of most-recent non-system groups that are always preserved.
|
||||
/// This is a hard floor that compaction cannot exceed, regardless of the target condition.
|
||||
/// </summary>
|
||||
public int MinimumPreserved { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the prompt used when requesting summaries from the chat client.
|
||||
/// </summary>
|
||||
public string SummarizationPrompt { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override async Task<bool> ApplyCompactionAsync(MessageIndex index, CancellationToken cancellationToken)
|
||||
{
|
||||
// Count non-system, non-excluded groups to determine which are protected
|
||||
int nonSystemIncludedCount = 0;
|
||||
for (int i = 0; i < index.Groups.Count; i++)
|
||||
{
|
||||
MessageGroup group = index.Groups[i];
|
||||
if (!group.IsExcluded && group.Kind != MessageGroupKind.System)
|
||||
{
|
||||
nonSystemIncludedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
int protectedFromEnd = Math.Min(this.MinimumPreserved, nonSystemIncludedCount);
|
||||
int maxSummarizable = nonSystemIncludedCount - protectedFromEnd;
|
||||
|
||||
if (maxSummarizable <= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Mark oldest non-system groups for summarization one at a time until the target is met
|
||||
StringBuilder conversationText = new();
|
||||
int summarized = 0;
|
||||
int insertIndex = -1;
|
||||
|
||||
for (int i = 0; i < index.Groups.Count && summarized < maxSummarizable; i++)
|
||||
{
|
||||
MessageGroup group = index.Groups[i];
|
||||
if (group.IsExcluded || group.Kind == MessageGroupKind.System)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (insertIndex < 0)
|
||||
{
|
||||
insertIndex = i;
|
||||
}
|
||||
|
||||
// Build text representation of the group for summarization
|
||||
foreach (ChatMessage message in group.Messages)
|
||||
{
|
||||
string text = message.Text ?? string.Empty;
|
||||
if (!string.IsNullOrEmpty(text))
|
||||
{
|
||||
conversationText.AppendLine($"{message.Role}: {text}");
|
||||
}
|
||||
}
|
||||
|
||||
group.IsExcluded = true;
|
||||
group.ExcludeReason = $"Summarized by {nameof(SummarizationCompactionStrategy)}";
|
||||
summarized++;
|
||||
|
||||
// Stop marking when target condition is met
|
||||
if (this.Target(index))
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Generate summary using the chat client (single LLM call for all marked groups)
|
||||
ChatResponse response = await this.ChatClient.GetResponseAsync(
|
||||
[
|
||||
new ChatMessage(ChatRole.System, this.SummarizationPrompt),
|
||||
.. index.Groups
|
||||
.Where(g => !g.IsExcluded && g.Kind == MessageGroupKind.System)
|
||||
.SelectMany(g => g.Messages),
|
||||
new ChatMessage(ChatRole.User, conversationText.ToString()),
|
||||
new ChatMessage(ChatRole.User, "Summarize the conversation above concisely."),
|
||||
],
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
string summaryText = string.IsNullOrWhiteSpace(response.Text) ? "[Summary unavailable]" : response.Text;
|
||||
|
||||
// Insert a summary group at the position of the first summarized group
|
||||
ChatMessage summaryMessage = new(ChatRole.Assistant, $"[Summary]\n{summaryText}");
|
||||
(summaryMessage.AdditionalProperties ??= [])[MessageGroup.SummaryPropertyKey] = true;
|
||||
|
||||
index.InsertGroup(insertIndex, MessageGroupKind.Summary, [summaryMessage]);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// A compaction strategy that collapses old tool call groups into single concise assistant
|
||||
/// messages, removing the detailed tool results while preserving a record of which tools were called.
|
||||
/// </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="MessageGroupKind.ToolCall"/>
|
||||
/// groups 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>
|
||||
/// <see cref="MinimumPreserved"/> is a hard floor: even if the <see cref="CompactionStrategy.Target"/>
|
||||
/// has not been reached, compaction will not touch the last <see cref="MinimumPreserved"/> non-system groups.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The <see cref="CompactionTrigger"/> predicate controls when compaction proceeds.
|
||||
/// When <see langword="null"/>, a default compound trigger of
|
||||
/// <see cref="CompactionTriggers.TokensExceed"/> AND <see cref="CompactionTriggers.HasToolCalls"/>
|
||||
/// is used.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class ToolResultCompactionStrategy : CompactionStrategy
|
||||
{
|
||||
/// <summary>
|
||||
/// The default minimum number of most-recent non-system groups to preserve.
|
||||
/// </summary>
|
||||
public const int DefaultMinimumPreserved = 2;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ToolResultCompactionStrategy"/> class.
|
||||
/// </summary>
|
||||
/// <param name="trigger">
|
||||
/// The <see cref="CompactionTrigger"/> that controls when compaction proceeds.
|
||||
/// </param>
|
||||
/// <param name="minimumPreserved">
|
||||
/// The minimum number of most-recent non-system message groups to preserve.
|
||||
/// This is a hard floor — compaction will not collapse groups beyond this limit,
|
||||
/// regardless of the target condition.
|
||||
/// Defaults to <see cref="DefaultMinimumPreserved"/>, ensuring the current turn's tool interactions remain visible.
|
||||
/// </param>
|
||||
/// <param name="target">
|
||||
/// An optional target condition that controls when compaction stops. When <see langword="null"/>,
|
||||
/// defaults to the inverse of the <paramref name="trigger"/> — compaction stops as soon as the trigger would no longer fire.
|
||||
/// </param>
|
||||
public ToolResultCompactionStrategy(CompactionTrigger trigger, int minimumPreserved = DefaultMinimumPreserved, CompactionTrigger? target = null)
|
||||
: base(trigger, target)
|
||||
{
|
||||
this.MinimumPreserved = minimumPreserved;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the minimum number of most-recent non-system groups that are always preserved.
|
||||
/// This is a hard floor that compaction cannot exceed, regardless of the target condition.
|
||||
/// </summary>
|
||||
public int MinimumPreserved { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override Task<bool> ApplyCompactionAsync(MessageIndex index, CancellationToken cancellationToken)
|
||||
{
|
||||
// Identify protected groups: the N most-recent non-system, non-excluded groups
|
||||
List<int> nonSystemIncludedIndices = [];
|
||||
for (int i = 0; i < index.Groups.Count; i++)
|
||||
{
|
||||
MessageGroup group = index.Groups[i];
|
||||
if (!group.IsExcluded && group.Kind != MessageGroupKind.System)
|
||||
{
|
||||
nonSystemIncludedIndices.Add(i);
|
||||
}
|
||||
}
|
||||
|
||||
int protectedStart = Math.Max(0, nonSystemIncludedIndices.Count - this.MinimumPreserved);
|
||||
HashSet<int> protectedGroupIndices = [];
|
||||
for (int i = protectedStart; i < nonSystemIncludedIndices.Count; i++)
|
||||
{
|
||||
protectedGroupIndices.Add(nonSystemIncludedIndices[i]);
|
||||
}
|
||||
|
||||
// Collect eligible tool groups in order (oldest first)
|
||||
List<int> eligibleIndices = [];
|
||||
for (int i = 0; i < index.Groups.Count; i++)
|
||||
{
|
||||
MessageGroup group = index.Groups[i];
|
||||
if (!group.IsExcluded && group.Kind == MessageGroupKind.ToolCall && !protectedGroupIndices.Contains(i))
|
||||
{
|
||||
eligibleIndices.Add(i);
|
||||
}
|
||||
}
|
||||
|
||||
if (eligibleIndices.Count == 0)
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
// Collapse one tool group at a time from oldest, re-checking target after each
|
||||
bool compacted = false;
|
||||
int offset = 0;
|
||||
|
||||
for (int e = 0; e < eligibleIndices.Count; e++)
|
||||
{
|
||||
int idx = eligibleIndices[e] + offset;
|
||||
MessageGroup group = index.Groups[idx];
|
||||
|
||||
// Extract tool names from FunctionCallContent
|
||||
List<string> toolNames = [];
|
||||
foreach (ChatMessage message in group.Messages)
|
||||
{
|
||||
if (message.Contents is not null)
|
||||
{
|
||||
foreach (AIContent content in message.Contents)
|
||||
{
|
||||
if (content is FunctionCallContent fcc)
|
||||
{
|
||||
toolNames.Add(fcc.Name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Exclude the original group and insert a collapsed replacement
|
||||
group.IsExcluded = true;
|
||||
group.ExcludeReason = $"Collapsed by {nameof(ToolResultCompactionStrategy)}";
|
||||
|
||||
string summary = $"[Tool calls: {string.Join(", ", toolNames)}]";
|
||||
index.InsertGroup(idx + 1, MessageGroupKind.AssistantText, [new ChatMessage(ChatRole.Assistant, summary)], group.TurnIndex);
|
||||
offset++; // Each insertion shifts subsequent indices by 1
|
||||
|
||||
compacted = true;
|
||||
|
||||
// Stop when target condition is met
|
||||
if (this.Target(index))
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult(compacted);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Microsoft.Agents.AI.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// A compaction strategy that removes the oldest non-system message groups,
|
||||
/// keeping at least <see cref="MinimumPreserved"/> most-recent groups intact.
|
||||
/// </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>
|
||||
/// <see cref="MinimumPreserved"/> is a hard floor: even if the <see cref="CompactionStrategy.Target"/>
|
||||
/// has not been reached, compaction will not touch the last <see cref="MinimumPreserved"/> non-system groups.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The <see cref="CompactionTrigger"/> controls when compaction proceeds.
|
||||
/// Use <see cref="CompactionTriggers"/> for common trigger conditions such as token or group thresholds.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class TruncationCompactionStrategy : CompactionStrategy
|
||||
{
|
||||
/// <summary>
|
||||
/// The default minimum number of most-recent non-system groups to preserve.
|
||||
/// </summary>
|
||||
public const int DefaultMinimumPreserved = 32;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TruncationCompactionStrategy"/> class.
|
||||
/// </summary>
|
||||
/// <param name="trigger">
|
||||
/// The <see cref="CompactionTrigger"/> that controls when compaction proceeds.
|
||||
/// </param>
|
||||
/// <param name="minimumPreserved">
|
||||
/// The minimum number of most-recent non-system message groups to preserve.
|
||||
/// This is a hard floor — compaction will not remove groups beyond this limit,
|
||||
/// regardless of the target condition.
|
||||
/// </param>
|
||||
/// <param name="target">
|
||||
/// An optional target condition that controls when compaction stops. When <see langword="null"/>,
|
||||
/// defaults to the inverse of the <paramref name="trigger"/> — compaction stops as soon as the trigger would no longer fire.
|
||||
/// </param>
|
||||
public TruncationCompactionStrategy(CompactionTrigger trigger, int minimumPreserved = DefaultMinimumPreserved, CompactionTrigger? target = null)
|
||||
: base(trigger, target)
|
||||
{
|
||||
this.MinimumPreserved = minimumPreserved;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the minimum number of most-recent non-system message groups that are always preserved.
|
||||
/// This is a hard floor that compaction cannot exceed, regardless of the target condition.
|
||||
/// </summary>
|
||||
public int MinimumPreserved { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override Task<bool> ApplyCompactionAsync(MessageIndex index, CancellationToken cancellationToken)
|
||||
{
|
||||
// Count removable (non-system, non-excluded) groups
|
||||
int removableCount = 0;
|
||||
for (int i = 0; i < index.Groups.Count; i++)
|
||||
{
|
||||
MessageGroup group = index.Groups[i];
|
||||
if (!group.IsExcluded && group.Kind != MessageGroupKind.System)
|
||||
{
|
||||
removableCount++;
|
||||
}
|
||||
}
|
||||
|
||||
int maxRemovable = removableCount - this.MinimumPreserved;
|
||||
if (maxRemovable <= 0)
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
// Exclude oldest non-system groups one at a time, re-checking target after each
|
||||
bool compacted = false;
|
||||
int removed = 0;
|
||||
for (int i = 0; i < index.Groups.Count && removed < maxRemovable; i++)
|
||||
{
|
||||
MessageGroup group = index.Groups[i];
|
||||
if (group.IsExcluded || group.Kind == MessageGroupKind.System)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
group.IsExcluded = true;
|
||||
group.ExcludeReason = $"Truncated by {nameof(TruncationCompactionStrategy)}";
|
||||
removed++;
|
||||
compacted = true;
|
||||
|
||||
// Stop when target condition is met
|
||||
if (this.Target(index))
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult(compacted);
|
||||
}
|
||||
}
|
||||
@@ -18,10 +18,14 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Microsoft.Agents.AI.Abstractions\Microsoft.Agents.AI.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.AI" />
|
||||
<PackageReference Include="Microsoft.Extensions.VectorData.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.ML.Tokenizers" />
|
||||
<PackageReference Include="System.Diagnostics.DiagnosticSource" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -36,7 +40,7 @@
|
||||
|
||||
<InternalsVisibleTo Include="Microsoft.Agents.AI.UnitTests" />
|
||||
<InternalsVisibleTo Include="Microsoft.Agents.AI.Declarative.UnitTests" />
|
||||
<InternalsVisibleTo Include="Microsoft.Agents.AI.Hosting.UnitTests"/>
|
||||
<InternalsVisibleTo Include="Microsoft.Agents.AI.Hosting.UnitTests" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,399 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Reflection;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Compaction;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.UnitTests.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// Contains tests for the <see cref="CompactingChatClient"/> class.
|
||||
/// </summary>
|
||||
public sealed class CompactingChatClientTests : IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// Restores the static <see cref="AIAgent.CurrentRunContext"/> after each test.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
SetCurrentRunContext(null);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConstructorThrowsOnNullStrategyAsync()
|
||||
{
|
||||
Mock<IChatClient> mockInner = new();
|
||||
Assert.Throws<ArgumentNullException>(() => new CompactingChatClient(mockInner.Object, null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetResponseAsyncNoContextPassesThroughAsync()
|
||||
{
|
||||
// Arrange — no CurrentRunContext set → passthrough
|
||||
ChatResponse expectedResponse = new([new ChatMessage(ChatRole.Assistant, "Hi")]);
|
||||
Mock<IChatClient> mockInner = new();
|
||||
mockInner.Setup(c => c.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(expectedResponse);
|
||||
|
||||
TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(100000));
|
||||
CompactingChatClient client = new(mockInner.Object, strategy);
|
||||
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Hello"),
|
||||
];
|
||||
|
||||
// Act
|
||||
ChatResponse response = await client.GetResponseAsync(messages);
|
||||
|
||||
// Assert
|
||||
Assert.Same(expectedResponse, response);
|
||||
mockInner.Verify(c => c.GetResponseAsync(
|
||||
messages,
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetResponseAsyncWithContextAppliesCompactionAsync()
|
||||
{
|
||||
// Arrange — set CurrentRunContext so compaction runs
|
||||
ChatResponse expectedResponse = new([new ChatMessage(ChatRole.Assistant, "Done")]);
|
||||
List<ChatMessage>? capturedMessages = null;
|
||||
Mock<IChatClient> mockInner = new();
|
||||
mockInner.Setup(c => c.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback<IEnumerable<ChatMessage>, ChatOptions?, CancellationToken>((msgs, _, _) =>
|
||||
capturedMessages = [.. msgs])
|
||||
.ReturnsAsync(expectedResponse);
|
||||
|
||||
// Strategy that always triggers and keeps only 1 group
|
||||
TruncationCompactionStrategy strategy = new(_ => true, minimumPreserved: 1);
|
||||
CompactingChatClient client = new(mockInner.Object, strategy);
|
||||
|
||||
TestAgentSession session = new();
|
||||
SetRunContext(session);
|
||||
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, "A1"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
];
|
||||
|
||||
// Act
|
||||
ChatResponse response = await client.GetResponseAsync(messages);
|
||||
|
||||
// Assert — compaction should have removed oldest groups
|
||||
Assert.Same(expectedResponse, response);
|
||||
Assert.NotNull(capturedMessages);
|
||||
Assert.True(capturedMessages!.Count < messages.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetResponseAsyncNoCompactionNeededReturnsOriginalMessagesAsync()
|
||||
{
|
||||
// Arrange — trigger never fires → no compaction
|
||||
ChatResponse expectedResponse = new([new ChatMessage(ChatRole.Assistant, "Hi")]);
|
||||
List<ChatMessage>? capturedMessages = null;
|
||||
Mock<IChatClient> mockInner = new();
|
||||
mockInner.Setup(c => c.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback<IEnumerable<ChatMessage>, ChatOptions?, CancellationToken>((msgs, _, _) =>
|
||||
capturedMessages = [.. msgs])
|
||||
.ReturnsAsync(expectedResponse);
|
||||
|
||||
TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(100000));
|
||||
CompactingChatClient client = new(mockInner.Object, strategy);
|
||||
|
||||
TestAgentSession session = new();
|
||||
SetRunContext(session);
|
||||
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Hello"),
|
||||
];
|
||||
|
||||
// Act
|
||||
await client.GetResponseAsync(messages);
|
||||
|
||||
// Assert — original messages passed through
|
||||
Assert.NotNull(capturedMessages);
|
||||
Assert.Single(capturedMessages!);
|
||||
Assert.Equal("Hello", capturedMessages[0].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetResponseAsyncWithExistingIndexUpdatesAsync()
|
||||
{
|
||||
// Arrange — call twice to exercise the "existing index" path (state.MessageIndex.Count > 0)
|
||||
Mock<IChatClient> mockInner = new();
|
||||
mockInner.Setup(c => c.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ChatResponse([new ChatMessage(ChatRole.Assistant, "OK")]));
|
||||
|
||||
// Strategy that always triggers, keeping 1 group
|
||||
TruncationCompactionStrategy strategy = new(_ => true, minimumPreserved: 1);
|
||||
CompactingChatClient client = new(mockInner.Object, strategy);
|
||||
|
||||
TestAgentSession session = new();
|
||||
SetRunContext(session);
|
||||
|
||||
List<ChatMessage> messages1 =
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, "A1"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
];
|
||||
|
||||
// First call — initializes state
|
||||
await client.GetResponseAsync(messages1);
|
||||
|
||||
List<ChatMessage> messages2 =
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, "A1"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
new ChatMessage(ChatRole.Assistant, "A2"),
|
||||
new ChatMessage(ChatRole.User, "Q3"),
|
||||
];
|
||||
|
||||
// Act — second call exercises the update path
|
||||
ChatResponse response = await client.GetResponseAsync(messages2);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(response);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetResponseAsyncNullSessionReturnsOriginalAsync()
|
||||
{
|
||||
// Arrange — CurrentRunContext exists but Session is null
|
||||
ChatResponse expectedResponse = new([new ChatMessage(ChatRole.Assistant, "Hi")]);
|
||||
Mock<IChatClient> mockInner = new();
|
||||
mockInner.Setup(c => c.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(expectedResponse);
|
||||
|
||||
TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(100000));
|
||||
CompactingChatClient client = new(mockInner.Object, strategy);
|
||||
|
||||
// Set context with null session
|
||||
SetRunContext(null);
|
||||
|
||||
List<ChatMessage> messages = [new ChatMessage(ChatRole.User, "Hello")];
|
||||
|
||||
// Act
|
||||
ChatResponse response = await client.GetResponseAsync(messages);
|
||||
|
||||
// Assert
|
||||
Assert.Same(expectedResponse, response);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStreamingResponseAsyncNoContextPassesThroughAsync()
|
||||
{
|
||||
// Arrange — no CurrentRunContext
|
||||
Mock<IChatClient> mockInner = new();
|
||||
ChatResponseUpdate[] updates = [new(ChatRole.Assistant, "Hi")];
|
||||
mockInner.Setup(c => c.GetStreamingResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Returns(ToAsyncEnumerableAsync(updates));
|
||||
|
||||
TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(100000));
|
||||
CompactingChatClient client = new(mockInner.Object, strategy);
|
||||
|
||||
List<ChatMessage> messages = [new ChatMessage(ChatRole.User, "Hello")];
|
||||
|
||||
// Act
|
||||
List<ChatResponseUpdate> results = [];
|
||||
await foreach (ChatResponseUpdate update in client.GetStreamingResponseAsync(messages))
|
||||
{
|
||||
results.Add(update);
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.Single(results);
|
||||
Assert.Equal("Hi", results[0].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStreamingResponseAsyncWithContextAppliesCompactionAsync()
|
||||
{
|
||||
// Arrange
|
||||
Mock<IChatClient> mockInner = new();
|
||||
ChatResponseUpdate[] updates = [new(ChatRole.Assistant, "Done")];
|
||||
mockInner.Setup(c => c.GetStreamingResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Returns(ToAsyncEnumerableAsync(updates));
|
||||
|
||||
TruncationCompactionStrategy strategy = new(_ => true, minimumPreserved: 1);
|
||||
CompactingChatClient client = new(mockInner.Object, strategy);
|
||||
|
||||
TestAgentSession session = new();
|
||||
SetRunContext(session);
|
||||
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, "A1"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
];
|
||||
|
||||
// Act
|
||||
List<ChatResponseUpdate> results = [];
|
||||
await foreach (ChatResponseUpdate update in client.GetStreamingResponseAsync(messages))
|
||||
{
|
||||
results.Add(update);
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.Single(results);
|
||||
Assert.Equal("Done", results[0].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetServiceReturnsStrategyForMatchingType()
|
||||
{
|
||||
// Arrange
|
||||
Mock<IChatClient> mockInner = new();
|
||||
TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(1000));
|
||||
CompactingChatClient client = new(mockInner.Object, strategy);
|
||||
|
||||
// Act — typeof(Type).IsInstanceOfType(typeof(CompactionStrategy)) is true
|
||||
object? result = client.GetService(typeof(Type));
|
||||
|
||||
// Assert
|
||||
Assert.Same(strategy, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetServiceDelegatesToBaseForNonMatchingType()
|
||||
{
|
||||
// Arrange
|
||||
Mock<IChatClient> mockInner = new();
|
||||
TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(1000));
|
||||
CompactingChatClient client = new(mockInner.Object, strategy);
|
||||
|
||||
// Act — typeof(string) doesn't match
|
||||
object? result = client.GetService(typeof(string));
|
||||
|
||||
// Assert — delegates to base (which returns null for unregistered types)
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetServiceThrowsOnNullType()
|
||||
{
|
||||
Mock<IChatClient> mockInner = new();
|
||||
TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(1000));
|
||||
CompactingChatClient client = new(mockInner.Object, strategy);
|
||||
|
||||
Assert.Throws<ArgumentNullException>(() => client.GetService(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetServiceWithServiceKeyDelegatesToBase()
|
||||
{
|
||||
// Arrange — non-null serviceKey always delegates
|
||||
Mock<IChatClient> mockInner = new();
|
||||
TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(1000));
|
||||
CompactingChatClient client = new(mockInner.Object, strategy);
|
||||
|
||||
// Act
|
||||
object? result = client.GetService(typeof(Type), serviceKey: "mykey");
|
||||
|
||||
// Assert — delegates to base because serviceKey is non-null
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetResponseAsyncMessagesNotListCreatesListCopyAsync()
|
||||
{
|
||||
// Arrange — pass IEnumerable (not List<ChatMessage>) to exercise the list copy branch
|
||||
ChatResponse expectedResponse = new([new ChatMessage(ChatRole.Assistant, "Hi")]);
|
||||
Mock<IChatClient> mockInner = new();
|
||||
mockInner.Setup(c => c.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(expectedResponse);
|
||||
|
||||
TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(100000));
|
||||
CompactingChatClient client = new(mockInner.Object, strategy);
|
||||
|
||||
TestAgentSession session = new();
|
||||
SetRunContext(session);
|
||||
|
||||
// Use an IEnumerable (not a List) to trigger the copy path
|
||||
IEnumerable<ChatMessage> messages = [new(ChatRole.User, "Hello")];
|
||||
|
||||
// Act
|
||||
ChatResponse response = await client.GetResponseAsync(messages);
|
||||
|
||||
// Assert
|
||||
Assert.Same(expectedResponse, response);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets <see cref="AIAgent.CurrentRunContext"/> via reflection.
|
||||
/// </summary>
|
||||
private static void SetCurrentRunContext(AgentRunContext? context)
|
||||
{
|
||||
FieldInfo? field = typeof(AIAgent).GetField("s_currentContext", BindingFlags.NonPublic | BindingFlags.Static);
|
||||
Assert.NotNull(field);
|
||||
object? asyncLocal = field!.GetValue(null);
|
||||
Assert.NotNull(asyncLocal);
|
||||
PropertyInfo? valueProp = asyncLocal!.GetType().GetProperty("Value");
|
||||
Assert.NotNull(valueProp);
|
||||
valueProp!.SetValue(asyncLocal, context);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an <see cref="AgentRunContext"/> with the given session and sets it as the current context.
|
||||
/// </summary>
|
||||
private static void SetRunContext(AgentSession? session)
|
||||
{
|
||||
Mock<AIAgent> mockAgent = new() { CallBase = true };
|
||||
AgentRunContext context = new(
|
||||
mockAgent.Object,
|
||||
session,
|
||||
[new(ChatRole.User, "test")],
|
||||
null);
|
||||
SetCurrentRunContext(context);
|
||||
}
|
||||
|
||||
private static async IAsyncEnumerable<ChatResponseUpdate> ToAsyncEnumerableAsync(
|
||||
ChatResponseUpdate[] updates, [EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
foreach (ChatResponseUpdate update in updates)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
yield return update;
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestAgentSession : AgentSession;
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Compaction;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.UnitTests.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// Contains tests for the <see cref="CompactionStrategy"/> abstract base class.
|
||||
/// </summary>
|
||||
public class CompactionStrategyTests
|
||||
{
|
||||
[Fact]
|
||||
public void ConstructorNullTriggerThrows()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() => new TestStrategy(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncTriggerNotMetReturnsFalseAsync()
|
||||
{
|
||||
// Arrange — trigger never fires
|
||||
TestStrategy strategy = new(_ => false);
|
||||
MessageIndex index = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]);
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(index);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
Assert.Equal(0, strategy.ApplyCallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncTriggerMetCallsApplyAsync()
|
||||
{
|
||||
// Arrange — trigger always fires
|
||||
TestStrategy strategy = new(_ => true, applyFunc: _ => true);
|
||||
MessageIndex index = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]);
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(index);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
Assert.Equal(1, strategy.ApplyCallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncReturnsFalseWhenApplyReturnsFalseAsync()
|
||||
{
|
||||
// Arrange — trigger fires but Apply does nothing
|
||||
TestStrategy strategy = new(_ => true, applyFunc: _ => false);
|
||||
MessageIndex index = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]);
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(index);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
Assert.Equal(1, strategy.ApplyCallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncDefaultTargetIsInverseOfTriggerAsync()
|
||||
{
|
||||
// Arrange — trigger fires when groups > 2
|
||||
// Default target should be: stop when groups <= 2 (i.e., !trigger)
|
||||
CompactionTrigger trigger = CompactionTriggers.GroupsExceed(2);
|
||||
TestStrategy strategy = new(trigger, applyFunc: index =>
|
||||
{
|
||||
// Exclude oldest non-system group one at a time
|
||||
foreach (MessageGroup group in index.Groups)
|
||||
{
|
||||
if (!group.IsExcluded && group.Kind != MessageGroupKind.System)
|
||||
{
|
||||
group.IsExcluded = true;
|
||||
// Target (default = !trigger) returns true when groups <= 2
|
||||
// So the strategy would check Target after this exclusion
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
MessageIndex index = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, "A1"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
new ChatMessage(ChatRole.Assistant, "A2"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(index);
|
||||
|
||||
// Assert — trigger fires (4 > 2), Apply is called
|
||||
Assert.True(result);
|
||||
Assert.Equal(1, strategy.ApplyCallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncCustomTargetIsPassedToStrategyAsync()
|
||||
{
|
||||
// Arrange — custom target that always signals stop
|
||||
bool targetCalled = false;
|
||||
CompactionTrigger customTarget = _ =>
|
||||
{
|
||||
targetCalled = true;
|
||||
return true;
|
||||
};
|
||||
|
||||
TestStrategy strategy = new(_ => true, customTarget, _ =>
|
||||
{
|
||||
// Access the target from within the strategy
|
||||
return true;
|
||||
});
|
||||
|
||||
MessageIndex index = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]);
|
||||
|
||||
// Act
|
||||
await strategy.CompactAsync(index);
|
||||
|
||||
// Assert — the custom target is accessible (verified by TestStrategy checking it)
|
||||
Assert.Equal(1, strategy.ApplyCallCount);
|
||||
// The target is accessible to derived classes via the protected property
|
||||
Assert.True(strategy.InvokeTarget(index));
|
||||
Assert.True(targetCalled);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A concrete test implementation of <see cref="CompactionStrategy"/> for testing the base class.
|
||||
/// </summary>
|
||||
private sealed class TestStrategy : CompactionStrategy
|
||||
{
|
||||
private readonly Func<MessageIndex, bool>? _applyFunc;
|
||||
|
||||
public TestStrategy(
|
||||
CompactionTrigger trigger,
|
||||
CompactionTrigger? target = null,
|
||||
Func<MessageIndex, bool>? applyFunc = null)
|
||||
: base(trigger, target)
|
||||
{
|
||||
this._applyFunc = applyFunc;
|
||||
}
|
||||
|
||||
public int ApplyCallCount { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Exposes the protected Target property for test verification.
|
||||
/// </summary>
|
||||
public bool InvokeTarget(MessageIndex index) => this.Target(index);
|
||||
|
||||
protected override Task<bool> ApplyCompactionAsync(MessageIndex index, CancellationToken cancellationToken)
|
||||
{
|
||||
this.ApplyCallCount++;
|
||||
bool result = this._applyFunc?.Invoke(index) ?? false;
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI.Compaction;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.UnitTests.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// Contains tests for <see cref="CompactionTrigger"/> and <see cref="CompactionTriggers"/>.
|
||||
/// </summary>
|
||||
public class CompactionTriggersTests
|
||||
{
|
||||
[Fact]
|
||||
public void TokensExceedReturnsTrueWhenAboveThreshold()
|
||||
{
|
||||
// Arrange — use a long message to guarantee tokens > 0
|
||||
CompactionTrigger trigger = CompactionTriggers.TokensExceed(0);
|
||||
MessageIndex index = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello world")]);
|
||||
|
||||
// Act & Assert
|
||||
Assert.True(trigger(index));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TokensExceedReturnsFalseWhenBelowThreshold()
|
||||
{
|
||||
CompactionTrigger trigger = CompactionTriggers.TokensExceed(999_999);
|
||||
MessageIndex index = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hi")]);
|
||||
|
||||
Assert.False(trigger(index));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MessagesExceedReturnsExpectedResult()
|
||||
{
|
||||
CompactionTrigger trigger = CompactionTriggers.MessagesExceed(2);
|
||||
MessageIndex small = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "A"),
|
||||
new ChatMessage(ChatRole.User, "B"),
|
||||
]);
|
||||
MessageIndex large = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "A"),
|
||||
new ChatMessage(ChatRole.User, "B"),
|
||||
new ChatMessage(ChatRole.User, "C"),
|
||||
]);
|
||||
|
||||
Assert.False(trigger(small));
|
||||
Assert.True(trigger(large));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TurnsExceedReturnsExpectedResult()
|
||||
{
|
||||
CompactionTrigger trigger = CompactionTriggers.TurnsExceed(1);
|
||||
MessageIndex oneTurn = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, "A1"),
|
||||
]);
|
||||
MessageIndex twoTurns = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, "A1"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
]);
|
||||
|
||||
Assert.False(trigger(oneTurn));
|
||||
Assert.True(trigger(twoTurns));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GroupsExceedReturnsExpectedResult()
|
||||
{
|
||||
CompactionTrigger trigger = CompactionTriggers.GroupsExceed(2);
|
||||
MessageIndex index = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "A"),
|
||||
new ChatMessage(ChatRole.Assistant, "B"),
|
||||
new ChatMessage(ChatRole.User, "C"),
|
||||
]);
|
||||
|
||||
Assert.True(trigger(index));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasToolCallsReturnsTrueWhenToolCallGroupExists()
|
||||
{
|
||||
CompactionTrigger trigger = CompactionTriggers.HasToolCalls();
|
||||
MessageIndex index = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "fn")]),
|
||||
new ChatMessage(ChatRole.Tool, "result"),
|
||||
]);
|
||||
|
||||
Assert.True(trigger(index));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasToolCallsReturnsFalseWhenNoToolCallGroup()
|
||||
{
|
||||
CompactionTrigger trigger = CompactionTriggers.HasToolCalls();
|
||||
MessageIndex index = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Hello"),
|
||||
new ChatMessage(ChatRole.Assistant, "Hi!"),
|
||||
]);
|
||||
|
||||
Assert.False(trigger(index));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllRequiresAllConditions()
|
||||
{
|
||||
CompactionTrigger trigger = CompactionTriggers.All(
|
||||
CompactionTriggers.TokensExceed(0),
|
||||
CompactionTriggers.MessagesExceed(5));
|
||||
|
||||
MessageIndex small = MessageIndex.Create([new ChatMessage(ChatRole.User, "A")]);
|
||||
|
||||
// Tokens > 0 is true, but messages > 5 is false
|
||||
Assert.False(trigger(small));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnyRequiresAtLeastOneCondition()
|
||||
{
|
||||
CompactionTrigger trigger = CompactionTriggers.Any(
|
||||
CompactionTriggers.TokensExceed(999_999),
|
||||
CompactionTriggers.MessagesExceed(0));
|
||||
|
||||
MessageIndex index = MessageIndex.Create([new ChatMessage(ChatRole.User, "A")]);
|
||||
|
||||
// Tokens not exceeded, but messages > 0 is true
|
||||
Assert.True(trigger(index));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllEmptyTriggersReturnsTrue()
|
||||
{
|
||||
CompactionTrigger trigger = CompactionTriggers.All();
|
||||
MessageIndex index = MessageIndex.Create([new ChatMessage(ChatRole.User, "A")]);
|
||||
Assert.True(trigger(index));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnyEmptyTriggersReturnsFalse()
|
||||
{
|
||||
CompactionTrigger trigger = CompactionTriggers.Any();
|
||||
MessageIndex index = MessageIndex.Create([new ChatMessage(ChatRole.User, "A")]);
|
||||
Assert.False(trigger(index));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TokensBelowReturnsTrueWhenBelowThreshold()
|
||||
{
|
||||
CompactionTrigger trigger = CompactionTriggers.TokensBelow(999_999);
|
||||
MessageIndex index = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hi")]);
|
||||
|
||||
Assert.True(trigger(index));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TokensBelowReturnsFalseWhenAboveThreshold()
|
||||
{
|
||||
CompactionTrigger trigger = CompactionTriggers.TokensBelow(0);
|
||||
MessageIndex index = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello world")]);
|
||||
|
||||
Assert.False(trigger(index));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlwaysReturnsTrue()
|
||||
{
|
||||
MessageIndex index = MessageIndex.Create([new ChatMessage(ChatRole.User, "A")]);
|
||||
Assert.True(CompactionTriggers.Always(index));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,911 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Buffers;
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.Agents.AI.Compaction;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.UnitTests.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// Contains tests for the <see cref="MessageIndex"/> class.
|
||||
/// </summary>
|
||||
public class MessageIndexTests
|
||||
{
|
||||
[Fact]
|
||||
public void CreateEmptyListReturnsEmptyGroups()
|
||||
{
|
||||
// Arrange
|
||||
List<ChatMessage> messages = [];
|
||||
|
||||
// Act
|
||||
MessageIndex groups = MessageIndex.Create(messages);
|
||||
|
||||
// Assert
|
||||
Assert.Empty(groups.Groups);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateSystemMessageCreatesSystemGroup()
|
||||
{
|
||||
// Arrange
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new ChatMessage(ChatRole.System, "You are helpful."),
|
||||
];
|
||||
|
||||
// Act
|
||||
MessageIndex groups = MessageIndex.Create(messages);
|
||||
|
||||
// Assert
|
||||
Assert.Single(groups.Groups);
|
||||
Assert.Equal(MessageGroupKind.System, groups.Groups[0].Kind);
|
||||
Assert.Single(groups.Groups[0].Messages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateUserMessageCreatesUserGroup()
|
||||
{
|
||||
// Arrange
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Hello"),
|
||||
];
|
||||
|
||||
// Act
|
||||
MessageIndex groups = MessageIndex.Create(messages);
|
||||
|
||||
// Assert
|
||||
Assert.Single(groups.Groups);
|
||||
Assert.Equal(MessageGroupKind.User, groups.Groups[0].Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateAssistantTextMessageCreatesAssistantTextGroup()
|
||||
{
|
||||
// Arrange
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new ChatMessage(ChatRole.Assistant, "Hi there!"),
|
||||
];
|
||||
|
||||
// Act
|
||||
MessageIndex groups = MessageIndex.Create(messages);
|
||||
|
||||
// Assert
|
||||
Assert.Single(groups.Groups);
|
||||
Assert.Equal(MessageGroupKind.AssistantText, groups.Groups[0].Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateToolCallWithResultsCreatesAtomicToolCallGroup()
|
||||
{
|
||||
// Arrange
|
||||
ChatMessage assistantMessage = new(ChatRole.Assistant, [new FunctionCallContent("call1", "get_weather", new Dictionary<string, object?> { ["city"] = "Seattle" })]);
|
||||
ChatMessage toolResult = new(ChatRole.Tool, "Sunny, 72°F");
|
||||
|
||||
List<ChatMessage> messages = [assistantMessage, toolResult];
|
||||
|
||||
// Act
|
||||
MessageIndex groups = MessageIndex.Create(messages);
|
||||
|
||||
// Assert
|
||||
Assert.Single(groups.Groups);
|
||||
Assert.Equal(MessageGroupKind.ToolCall, groups.Groups[0].Kind);
|
||||
Assert.Equal(2, groups.Groups[0].Messages.Count);
|
||||
Assert.Same(assistantMessage, groups.Groups[0].Messages[0]);
|
||||
Assert.Same(toolResult, groups.Groups[0].Messages[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateMixedConversationGroupsCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
ChatMessage systemMsg = new(ChatRole.System, "You are helpful.");
|
||||
ChatMessage userMsg = new(ChatRole.User, "What's the weather?");
|
||||
ChatMessage assistantToolCall = new(ChatRole.Assistant, [new FunctionCallContent("call1", "get_weather")]);
|
||||
ChatMessage toolResult = new(ChatRole.Tool, "Sunny");
|
||||
ChatMessage assistantText = new(ChatRole.Assistant, "The weather is sunny!");
|
||||
|
||||
List<ChatMessage> messages = [systemMsg, userMsg, assistantToolCall, toolResult, assistantText];
|
||||
|
||||
// Act
|
||||
MessageIndex groups = MessageIndex.Create(messages);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(4, groups.Groups.Count);
|
||||
Assert.Equal(MessageGroupKind.System, groups.Groups[0].Kind);
|
||||
Assert.Equal(MessageGroupKind.User, groups.Groups[1].Kind);
|
||||
Assert.Equal(MessageGroupKind.ToolCall, groups.Groups[2].Kind);
|
||||
Assert.Equal(2, groups.Groups[2].Messages.Count);
|
||||
Assert.Equal(MessageGroupKind.AssistantText, groups.Groups[3].Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateMultipleToolResultsGroupsAllWithAssistant()
|
||||
{
|
||||
// Arrange
|
||||
ChatMessage assistantToolCall = new(ChatRole.Assistant, [
|
||||
new FunctionCallContent("call1", "get_weather"),
|
||||
new FunctionCallContent("call2", "get_time"),
|
||||
]);
|
||||
ChatMessage toolResult1 = new(ChatRole.Tool, "Sunny");
|
||||
ChatMessage toolResult2 = new(ChatRole.Tool, "3:00 PM");
|
||||
|
||||
List<ChatMessage> messages = [assistantToolCall, toolResult1, toolResult2];
|
||||
|
||||
// Act
|
||||
MessageIndex groups = MessageIndex.Create(messages);
|
||||
|
||||
// Assert
|
||||
Assert.Single(groups.Groups);
|
||||
Assert.Equal(MessageGroupKind.ToolCall, groups.Groups[0].Kind);
|
||||
Assert.Equal(3, groups.Groups[0].Messages.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetIncludedMessagesExcludesMarkedGroups()
|
||||
{
|
||||
// Arrange
|
||||
ChatMessage msg1 = new(ChatRole.User, "First");
|
||||
ChatMessage msg2 = new(ChatRole.Assistant, "Response");
|
||||
ChatMessage msg3 = new(ChatRole.User, "Second");
|
||||
|
||||
MessageIndex groups = MessageIndex.Create([msg1, msg2, msg3]);
|
||||
groups.Groups[1].IsExcluded = true;
|
||||
|
||||
// Act
|
||||
List<ChatMessage> included = [.. groups.GetIncludedMessages()];
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, included.Count);
|
||||
Assert.Same(msg1, included[0]);
|
||||
Assert.Same(msg3, included[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAllMessagesIncludesExcludedGroups()
|
||||
{
|
||||
// Arrange
|
||||
ChatMessage msg1 = new(ChatRole.User, "First");
|
||||
ChatMessage msg2 = new(ChatRole.Assistant, "Response");
|
||||
|
||||
MessageIndex groups = MessageIndex.Create([msg1, msg2]);
|
||||
groups.Groups[0].IsExcluded = true;
|
||||
|
||||
// Act
|
||||
List<ChatMessage> all = [.. groups.GetAllMessages()];
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, all.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IncludedGroupCountReflectsExclusions()
|
||||
{
|
||||
// Arrange
|
||||
MessageIndex groups = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "A"),
|
||||
new ChatMessage(ChatRole.Assistant, "B"),
|
||||
new ChatMessage(ChatRole.User, "C"),
|
||||
]);
|
||||
|
||||
groups.Groups[1].IsExcluded = true;
|
||||
|
||||
// Act & Assert
|
||||
Assert.Equal(2, groups.IncludedGroupCount);
|
||||
Assert.Equal(2, groups.IncludedMessageCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateSummaryMessageCreatesSummaryGroup()
|
||||
{
|
||||
// Arrange
|
||||
ChatMessage summaryMessage = new(ChatRole.Assistant, "[Summary of earlier conversation]: key facts...");
|
||||
(summaryMessage.AdditionalProperties ??= [])[MessageGroup.SummaryPropertyKey] = true;
|
||||
|
||||
List<ChatMessage> messages = [summaryMessage];
|
||||
|
||||
// Act
|
||||
MessageIndex groups = MessageIndex.Create(messages);
|
||||
|
||||
// Assert
|
||||
Assert.Single(groups.Groups);
|
||||
Assert.Equal(MessageGroupKind.Summary, groups.Groups[0].Kind);
|
||||
Assert.Same(summaryMessage, groups.Groups[0].Messages[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateSummaryAmongOtherMessagesGroupsCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
ChatMessage systemMsg = new(ChatRole.System, "You are helpful.");
|
||||
ChatMessage summaryMsg = new(ChatRole.Assistant, "[Summary]: previous context");
|
||||
(summaryMsg.AdditionalProperties ??= [])[MessageGroup.SummaryPropertyKey] = true;
|
||||
ChatMessage userMsg = new(ChatRole.User, "Continue...");
|
||||
|
||||
List<ChatMessage> messages = [systemMsg, summaryMsg, userMsg];
|
||||
|
||||
// Act
|
||||
MessageIndex groups = MessageIndex.Create(messages);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(3, groups.Groups.Count);
|
||||
Assert.Equal(MessageGroupKind.System, groups.Groups[0].Kind);
|
||||
Assert.Equal(MessageGroupKind.Summary, groups.Groups[1].Kind);
|
||||
Assert.Equal(MessageGroupKind.User, groups.Groups[2].Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MessageGroupStoresPassedCounts()
|
||||
{
|
||||
// Arrange & Act
|
||||
MessageGroup group = new(MessageGroupKind.User, [new ChatMessage(ChatRole.User, "Hello")], byteCount: 5, tokenCount: 2);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(1, group.MessageCount);
|
||||
Assert.Equal(5, group.ByteCount);
|
||||
Assert.Equal(2, group.TokenCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MessageGroupMessagesAreImmutable()
|
||||
{
|
||||
// Arrange
|
||||
IReadOnlyList<ChatMessage> messages = [new ChatMessage(ChatRole.User, "Hello")];
|
||||
MessageGroup group = new(MessageGroupKind.User, messages, byteCount: 5, tokenCount: 1);
|
||||
|
||||
// Assert — Messages is IReadOnlyList, not IList
|
||||
Assert.IsAssignableFrom<IReadOnlyList<ChatMessage>>(group.Messages);
|
||||
Assert.Same(messages, group.Messages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateComputesByteCountUtf8()
|
||||
{
|
||||
// Arrange — "Hello" is 5 UTF-8 bytes
|
||||
MessageIndex groups = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(5, groups.Groups[0].ByteCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateComputesByteCountMultiByteChars()
|
||||
{
|
||||
// Arrange — "café" has a multi-byte 'é' (2 bytes in UTF-8) → 5 bytes total
|
||||
MessageIndex groups = MessageIndex.Create([new ChatMessage(ChatRole.User, "café")]);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(5, groups.Groups[0].ByteCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateComputesByteCountMultipleMessagesInGroup()
|
||||
{
|
||||
// Arrange — ToolCall group: assistant (tool call, null text) + tool result "OK" (2 bytes)
|
||||
ChatMessage assistantMsg = new(ChatRole.Assistant, [new FunctionCallContent("call1", "fn")]);
|
||||
ChatMessage toolResult = new(ChatRole.Tool, "OK");
|
||||
MessageIndex groups = MessageIndex.Create([assistantMsg, toolResult]);
|
||||
|
||||
// Assert — single ToolCall group with 2 messages
|
||||
Assert.Single(groups.Groups);
|
||||
Assert.Equal(2, groups.Groups[0].MessageCount);
|
||||
Assert.Equal(2, groups.Groups[0].ByteCount); // "OK" = 2 bytes, assistant text is null
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateDefaultTokenCountIsHeuristic()
|
||||
{
|
||||
// Arrange — "Hello world test data!" = 22 UTF-8 bytes → 22 / 4 = 5 estimated tokens
|
||||
MessageIndex groups = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello world test data!")]);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(22, groups.Groups[0].ByteCount);
|
||||
Assert.Equal(22 / 4, groups.Groups[0].TokenCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateNullTextHasZeroCounts()
|
||||
{
|
||||
// Arrange — message with no text (e.g., pure function call)
|
||||
ChatMessage msg = new(ChatRole.Assistant, [new FunctionCallContent("call1", "get_weather")]);
|
||||
ChatMessage tool = new(ChatRole.Tool, string.Empty);
|
||||
MessageIndex groups = MessageIndex.Create([msg, tool]);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, groups.Groups[0].MessageCount);
|
||||
Assert.Equal(0, groups.Groups[0].ByteCount);
|
||||
Assert.Equal(0, groups.Groups[0].TokenCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TotalAggregatesSumAllGroups()
|
||||
{
|
||||
// Arrange
|
||||
MessageIndex groups = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "AAAA"), // 4 bytes
|
||||
new ChatMessage(ChatRole.Assistant, "BBBB"), // 4 bytes
|
||||
]);
|
||||
|
||||
groups.Groups[0].IsExcluded = true;
|
||||
|
||||
// Act & Assert — totals include excluded groups
|
||||
Assert.Equal(2, groups.TotalGroupCount);
|
||||
Assert.Equal(2, groups.TotalMessageCount);
|
||||
Assert.Equal(8, groups.TotalByteCount);
|
||||
Assert.Equal(2, groups.TotalTokenCount); // Each group: 4 bytes / 4 = 1 token, 2 groups = 2
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IncludedAggregatesExcludeMarkedGroups()
|
||||
{
|
||||
// Arrange
|
||||
MessageIndex groups = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "AAAA"), // 4 bytes
|
||||
new ChatMessage(ChatRole.Assistant, "BBBB"), // 4 bytes
|
||||
new ChatMessage(ChatRole.User, "CCCC"), // 4 bytes
|
||||
]);
|
||||
|
||||
groups.Groups[0].IsExcluded = true;
|
||||
|
||||
// Act & Assert
|
||||
Assert.Equal(3, groups.TotalGroupCount);
|
||||
Assert.Equal(2, groups.IncludedGroupCount);
|
||||
Assert.Equal(3, groups.TotalMessageCount);
|
||||
Assert.Equal(2, groups.IncludedMessageCount);
|
||||
Assert.Equal(12, groups.TotalByteCount);
|
||||
Assert.Equal(8, groups.IncludedByteCount);
|
||||
Assert.Equal(3, groups.TotalTokenCount); // 12 / 4 = 3 (across 3 groups of 4 bytes each = 1+1+1)
|
||||
Assert.Equal(2, groups.IncludedTokenCount); // 8 / 4 = 2 (2 included groups of 4 bytes = 1+1)
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToolCallGroupAggregatesAcrossMessages()
|
||||
{
|
||||
// Arrange — tool call group with assistant "Ask" (3 bytes) + tool result "OK" (2 bytes)
|
||||
ChatMessage assistantMsg = new(ChatRole.Assistant, [new FunctionCallContent("call1", "fn")]);
|
||||
ChatMessage toolResult = new(ChatRole.Tool, "OK");
|
||||
|
||||
MessageIndex groups = MessageIndex.Create([assistantMsg, toolResult]);
|
||||
|
||||
// Assert — single group with 2 messages
|
||||
Assert.Single(groups.Groups);
|
||||
Assert.Equal(2, groups.Groups[0].MessageCount);
|
||||
Assert.Equal(2, groups.Groups[0].ByteCount); // assistant text is null (function call), tool result is "OK" = 2 bytes
|
||||
Assert.Equal(1, groups.TotalGroupCount);
|
||||
Assert.Equal(2, groups.TotalMessageCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateAssignsTurnIndicesSingleTurn()
|
||||
{
|
||||
// Arrange — System (no turn), User + Assistant = turn 1
|
||||
MessageIndex groups = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.System, "You are helpful."),
|
||||
new ChatMessage(ChatRole.User, "Hello"),
|
||||
new ChatMessage(ChatRole.Assistant, "Hi!"),
|
||||
]);
|
||||
|
||||
// Assert
|
||||
Assert.Null(groups.Groups[0].TurnIndex); // System
|
||||
Assert.Equal(1, groups.Groups[1].TurnIndex); // User
|
||||
Assert.Equal(1, groups.Groups[2].TurnIndex); // Assistant
|
||||
Assert.Equal(1, groups.TotalTurnCount);
|
||||
Assert.Equal(1, groups.IncludedTurnCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateAssignsTurnIndicesMultiTurn()
|
||||
{
|
||||
// Arrange — 3 user turns
|
||||
MessageIndex groups = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.System, "System prompt."),
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, "A1"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
new ChatMessage(ChatRole.Assistant, "A2"),
|
||||
new ChatMessage(ChatRole.User, "Q3"),
|
||||
]);
|
||||
|
||||
// Assert — 6 groups: System(null), User(1), Assistant(1), User(2), Assistant(2), User(3)
|
||||
Assert.Null(groups.Groups[0].TurnIndex);
|
||||
Assert.Equal(1, groups.Groups[1].TurnIndex);
|
||||
Assert.Equal(1, groups.Groups[2].TurnIndex);
|
||||
Assert.Equal(2, groups.Groups[3].TurnIndex);
|
||||
Assert.Equal(2, groups.Groups[4].TurnIndex);
|
||||
Assert.Equal(3, groups.Groups[5].TurnIndex);
|
||||
Assert.Equal(3, groups.TotalTurnCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateTurnSpansToolCallGroups()
|
||||
{
|
||||
// Arrange — turn 1 includes User, ToolCall, AssistantText
|
||||
ChatMessage assistantToolCall = new(ChatRole.Assistant, [new FunctionCallContent("call1", "get_weather")]);
|
||||
ChatMessage toolResult = new(ChatRole.Tool, "Sunny");
|
||||
|
||||
MessageIndex groups = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "What's the weather?"),
|
||||
assistantToolCall,
|
||||
toolResult,
|
||||
new ChatMessage(ChatRole.Assistant, "The weather is sunny!"),
|
||||
]);
|
||||
|
||||
// Assert — all 3 groups belong to turn 1
|
||||
Assert.Equal(3, groups.Groups.Count);
|
||||
Assert.Equal(1, groups.Groups[0].TurnIndex); // User
|
||||
Assert.Equal(1, groups.Groups[1].TurnIndex); // ToolCall
|
||||
Assert.Equal(1, groups.Groups[2].TurnIndex); // AssistantText
|
||||
Assert.Equal(1, groups.TotalTurnCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetTurnGroupsReturnsGroupsForSpecificTurn()
|
||||
{
|
||||
// Arrange
|
||||
MessageIndex groups = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.System, "System."),
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, "A1"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
new ChatMessage(ChatRole.Assistant, "A2"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
List<MessageGroup> turn1 = [.. groups.GetTurnGroups(1)];
|
||||
List<MessageGroup> turn2 = [.. groups.GetTurnGroups(2)];
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, turn1.Count);
|
||||
Assert.Equal(MessageGroupKind.User, turn1[0].Kind);
|
||||
Assert.Equal(MessageGroupKind.AssistantText, turn1[1].Kind);
|
||||
Assert.Equal(2, turn2.Count);
|
||||
Assert.Equal(MessageGroupKind.User, turn2[0].Kind);
|
||||
Assert.Equal(MessageGroupKind.AssistantText, turn2[1].Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IncludedTurnCountReflectsExclusions()
|
||||
{
|
||||
// Arrange — 2 turns, exclude all groups in turn 1
|
||||
MessageIndex groups = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, "A1"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
new ChatMessage(ChatRole.Assistant, "A2"),
|
||||
]);
|
||||
|
||||
groups.Groups[0].IsExcluded = true; // User Q1 (turn 1)
|
||||
groups.Groups[1].IsExcluded = true; // Assistant A1 (turn 1)
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, groups.TotalTurnCount);
|
||||
Assert.Equal(1, groups.IncludedTurnCount); // Only turn 2 has included groups
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TotalTurnCountZeroWhenNoUserMessages()
|
||||
{
|
||||
// Arrange — only system messages
|
||||
MessageIndex groups = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.System, "System."),
|
||||
]);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(0, groups.TotalTurnCount);
|
||||
Assert.Equal(0, groups.IncludedTurnCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IncludedTurnCountPartialExclusionStillCountsTurn()
|
||||
{
|
||||
// Arrange — turn 1 has 2 groups, only one excluded
|
||||
MessageIndex groups = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, "A1"),
|
||||
]);
|
||||
|
||||
groups.Groups[1].IsExcluded = true; // Exclude assistant but user is still included
|
||||
|
||||
// Assert — turn 1 still has one included group
|
||||
Assert.Equal(1, groups.TotalTurnCount);
|
||||
Assert.Equal(1, groups.IncludedTurnCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateAppendsNewMessagesIncrementally()
|
||||
{
|
||||
// Arrange — create with 2 messages
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, "A1"),
|
||||
];
|
||||
MessageIndex index = MessageIndex.Create(messages);
|
||||
Assert.Equal(2, index.Groups.Count);
|
||||
Assert.Equal(2, index.ProcessedMessageCount);
|
||||
|
||||
// Act — add 2 more messages and update
|
||||
messages.Add(new ChatMessage(ChatRole.User, "Q2"));
|
||||
messages.Add(new ChatMessage(ChatRole.Assistant, "A2"));
|
||||
index.Update(messages);
|
||||
|
||||
// Assert — should have 4 groups total, processed count updated
|
||||
Assert.Equal(4, index.Groups.Count);
|
||||
Assert.Equal(4, index.ProcessedMessageCount);
|
||||
Assert.Equal(MessageGroupKind.User, index.Groups[2].Kind);
|
||||
Assert.Equal(MessageGroupKind.AssistantText, index.Groups[3].Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateNoOpWhenNoNewMessages()
|
||||
{
|
||||
// Arrange
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
];
|
||||
MessageIndex index = MessageIndex.Create(messages);
|
||||
int originalCount = index.Groups.Count;
|
||||
|
||||
// Act — update with same count
|
||||
index.Update(messages);
|
||||
|
||||
// Assert — nothing changed
|
||||
Assert.Equal(originalCount, index.Groups.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateRebuildsWhenMessagesShrink()
|
||||
{
|
||||
// Arrange — create with 3 messages
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, "A1"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
];
|
||||
MessageIndex index = MessageIndex.Create(messages);
|
||||
Assert.Equal(3, index.Groups.Count);
|
||||
|
||||
// Exclude a group to verify rebuild clears state
|
||||
index.Groups[0].IsExcluded = true;
|
||||
|
||||
// Act — update with fewer messages (simulates storage compaction)
|
||||
List<ChatMessage> shortened =
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
];
|
||||
index.Update(shortened);
|
||||
|
||||
// Assert — rebuilt from scratch
|
||||
Assert.Single(index.Groups);
|
||||
Assert.False(index.Groups[0].IsExcluded);
|
||||
Assert.Equal(1, index.ProcessedMessageCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdatePreservesExistingGroupExclusionState()
|
||||
{
|
||||
// Arrange
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, "A1"),
|
||||
];
|
||||
MessageIndex index = MessageIndex.Create(messages);
|
||||
index.Groups[0].IsExcluded = true;
|
||||
index.Groups[0].ExcludeReason = "Test exclusion";
|
||||
|
||||
// Act — append new messages
|
||||
messages.Add(new ChatMessage(ChatRole.User, "Q2"));
|
||||
index.Update(messages);
|
||||
|
||||
// Assert — original exclusion state preserved
|
||||
Assert.True(index.Groups[0].IsExcluded);
|
||||
Assert.Equal("Test exclusion", index.Groups[0].ExcludeReason);
|
||||
Assert.Equal(3, index.Groups.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InsertGroupInsertsAtSpecifiedIndex()
|
||||
{
|
||||
// Arrange
|
||||
MessageIndex index = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
]);
|
||||
|
||||
// Act — insert between Q1 and Q2
|
||||
ChatMessage summaryMsg = new(ChatRole.Assistant, "[Summary]");
|
||||
MessageGroup inserted = index.InsertGroup(1, MessageGroupKind.Summary, [summaryMsg], turnIndex: 1);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(3, index.Groups.Count);
|
||||
Assert.Same(inserted, index.Groups[1]);
|
||||
Assert.Equal(MessageGroupKind.Summary, index.Groups[1].Kind);
|
||||
Assert.Equal("[Summary]", index.Groups[1].Messages[0].Text);
|
||||
Assert.Equal(1, inserted.TurnIndex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddGroupAppendsToEnd()
|
||||
{
|
||||
// Arrange
|
||||
MessageIndex index = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
ChatMessage msg = new(ChatRole.Assistant, "Appended");
|
||||
MessageGroup added = index.AddGroup(MessageGroupKind.AssistantText, [msg], turnIndex: 1);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, index.Groups.Count);
|
||||
Assert.Same(added, index.Groups[1]);
|
||||
Assert.Equal("Appended", index.Groups[1].Messages[0].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InsertGroupComputesByteAndTokenCounts()
|
||||
{
|
||||
// Arrange
|
||||
MessageIndex index = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
]);
|
||||
|
||||
// Act — insert a group with known text
|
||||
ChatMessage msg = new(ChatRole.Assistant, "Hello"); // 5 bytes, ~1 token (5/4)
|
||||
MessageGroup inserted = index.InsertGroup(0, MessageGroupKind.AssistantText, [msg]);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(5, inserted.ByteCount);
|
||||
Assert.Equal(1, inserted.TokenCount); // 5 / 4 = 1 (integer division)
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConstructorWithGroupsRestoresTurnIndex()
|
||||
{
|
||||
// Arrange — pre-existing groups with turn indices
|
||||
MessageGroup group1 = new(MessageGroupKind.User, [new ChatMessage(ChatRole.User, "Q1")], 2, 1, turnIndex: 1);
|
||||
MessageGroup group2 = new(MessageGroupKind.AssistantText, [new ChatMessage(ChatRole.Assistant, "A1")], 2, 1, turnIndex: 1);
|
||||
MessageGroup group3 = new(MessageGroupKind.User, [new ChatMessage(ChatRole.User, "Q2")], 2, 1, turnIndex: 2);
|
||||
List<MessageGroup> groups = [group1, group2, group3];
|
||||
|
||||
// Act — constructor should restore _currentTurn from the last group's TurnIndex
|
||||
MessageIndex index = new(groups);
|
||||
|
||||
// Assert — adding a new user message should get turn 3 (restored 2 + 1)
|
||||
index.Update(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, "A1"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
new ChatMessage(ChatRole.User, "Q3"),
|
||||
]);
|
||||
|
||||
// The new user group should have TurnIndex 3
|
||||
MessageGroup lastGroup = index.Groups[index.Groups.Count - 1];
|
||||
Assert.Equal(MessageGroupKind.User, lastGroup.Kind);
|
||||
Assert.NotNull(lastGroup.TurnIndex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConstructorWithEmptyGroupsHandlesGracefully()
|
||||
{
|
||||
// Arrange & Act — constructor with empty list
|
||||
MessageIndex index = new([]);
|
||||
|
||||
// Assert
|
||||
Assert.Empty(index.Groups);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConstructorWithGroupsWithoutTurnIndexSkipsRestore()
|
||||
{
|
||||
// Arrange — groups without turn indices (system messages)
|
||||
MessageGroup systemGroup = new(MessageGroupKind.System, [new ChatMessage(ChatRole.System, "Be helpful")], 10, 3, turnIndex: null);
|
||||
List<MessageGroup> groups = [systemGroup];
|
||||
|
||||
// Act — constructor won't find a TurnIndex to restore
|
||||
MessageIndex index = new(groups);
|
||||
|
||||
// Assert
|
||||
Assert.Single(index.Groups);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeTokenCountReturnsTokenCount()
|
||||
{
|
||||
// Arrange — call the public static method directly
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Hello world"),
|
||||
new ChatMessage(ChatRole.Assistant, "Greetings"),
|
||||
];
|
||||
|
||||
// Act — use a simple tokenizer that counts words (each word = 1 token)
|
||||
SimpleWordTokenizer tokenizer = new();
|
||||
int tokenCount = MessageIndex.ComputeTokenCount(messages, tokenizer);
|
||||
|
||||
// Assert — "Hello world" = 2, "Greetings" = 1 → 3 total
|
||||
Assert.Equal(3, tokenCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeTokenCountEmptyTextReturnsZero()
|
||||
{
|
||||
// Arrange — message with no text content
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new ChatMessage(ChatRole.User, [new FunctionCallContent("c1", "fn")]),
|
||||
];
|
||||
|
||||
SimpleWordTokenizer tokenizer = new();
|
||||
int tokenCount = MessageIndex.ComputeTokenCount(messages, tokenizer);
|
||||
|
||||
// Assert — no text content → 0 tokens
|
||||
Assert.Equal(0, tokenCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateWithTokenizerUsesTokenizerForCounts()
|
||||
{
|
||||
// Arrange
|
||||
SimpleWordTokenizer tokenizer = new();
|
||||
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Hello world test"),
|
||||
];
|
||||
|
||||
// Act
|
||||
MessageIndex index = MessageIndex.Create(messages, tokenizer);
|
||||
|
||||
// Assert — tokenizer counts words: "Hello world test" = 3 tokens
|
||||
Assert.Single(index.Groups);
|
||||
Assert.Equal(3, index.Groups[0].TokenCount);
|
||||
Assert.NotNull(index.Tokenizer);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InsertGroupWithTokenizerUsesTokenizer()
|
||||
{
|
||||
// Arrange
|
||||
SimpleWordTokenizer tokenizer = new();
|
||||
MessageIndex index = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Hello"),
|
||||
], tokenizer);
|
||||
|
||||
// Act
|
||||
ChatMessage msg = new(ChatRole.Assistant, "Hello world test message");
|
||||
MessageGroup inserted = index.InsertGroup(0, MessageGroupKind.AssistantText, [msg]);
|
||||
|
||||
// Assert — tokenizer counts words: "Hello world test message" = 4 tokens
|
||||
Assert.Equal(4, inserted.TokenCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateWithStandaloneToolMessageGroupsAsAssistantText()
|
||||
{
|
||||
// A Tool message not preceded by an assistant tool-call falls through to the else branch
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new ChatMessage(ChatRole.Tool, "Orphaned tool result"),
|
||||
];
|
||||
|
||||
MessageIndex index = MessageIndex.Create(messages);
|
||||
|
||||
// The Tool message should be grouped as AssistantText (the default fallback)
|
||||
Assert.Single(index.Groups);
|
||||
Assert.Equal(MessageGroupKind.AssistantText, index.Groups[0].Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateWithAssistantNonSummaryWithPropertiesFallsToAssistantText()
|
||||
{
|
||||
// Assistant message with AdditionalProperties but NOT a summary
|
||||
ChatMessage assistant = new(ChatRole.Assistant, "Regular response");
|
||||
(assistant.AdditionalProperties ??= [])["someOtherKey"] = "value";
|
||||
|
||||
MessageIndex index = MessageIndex.Create([assistant]);
|
||||
|
||||
Assert.Single(index.Groups);
|
||||
Assert.Equal(MessageGroupKind.AssistantText, index.Groups[0].Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeByteCountHandlesNullAndNonNullText()
|
||||
{
|
||||
// Mix of messages: one with text (non-null), one without (null Text)
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Hello"),
|
||||
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "fn")]),
|
||||
];
|
||||
|
||||
int byteCount = MessageIndex.ComputeByteCount(messages);
|
||||
|
||||
// Only "Hello" contributes bytes (5 bytes UTF-8)
|
||||
Assert.Equal(5, byteCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeTokenCountHandlesNullAndNonNullText()
|
||||
{
|
||||
// Mix: one with text, one without
|
||||
SimpleWordTokenizer tokenizer = new();
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Hello world"),
|
||||
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "fn")]),
|
||||
];
|
||||
|
||||
int tokenCount = MessageIndex.ComputeTokenCount(messages, tokenizer);
|
||||
|
||||
// Only "Hello world" contributes tokens (2 words)
|
||||
Assert.Equal(2, tokenCount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A simple tokenizer that counts whitespace-separated words as tokens.
|
||||
/// </summary>
|
||||
private sealed class SimpleWordTokenizer : Microsoft.ML.Tokenizers.Tokenizer
|
||||
{
|
||||
public override Microsoft.ML.Tokenizers.PreTokenizer? PreTokenizer => null;
|
||||
public override Microsoft.ML.Tokenizers.Normalizer? Normalizer => null;
|
||||
|
||||
protected override Microsoft.ML.Tokenizers.EncodeResults<Microsoft.ML.Tokenizers.EncodedToken> EncodeToTokens(string? text, System.ReadOnlySpan<char> textSpan, Microsoft.ML.Tokenizers.EncodeSettings settings)
|
||||
{
|
||||
// Simple word-based encoding
|
||||
string input = text ?? textSpan.ToString();
|
||||
if (string.IsNullOrWhiteSpace(input))
|
||||
{
|
||||
return new Microsoft.ML.Tokenizers.EncodeResults<Microsoft.ML.Tokenizers.EncodedToken>
|
||||
{
|
||||
Tokens = System.Array.Empty<Microsoft.ML.Tokenizers.EncodedToken>(),
|
||||
CharsConsumed = 0,
|
||||
NormalizedText = null,
|
||||
};
|
||||
}
|
||||
|
||||
string[] words = input.Split(' ');
|
||||
List<Microsoft.ML.Tokenizers.EncodedToken> tokens = [];
|
||||
int offset = 0;
|
||||
for (int i = 0; i < words.Length; i++)
|
||||
{
|
||||
tokens.Add(new Microsoft.ML.Tokenizers.EncodedToken(i, words[i], new System.Range(offset, offset + words[i].Length)));
|
||||
offset += words[i].Length + 1;
|
||||
}
|
||||
|
||||
return new Microsoft.ML.Tokenizers.EncodeResults<Microsoft.ML.Tokenizers.EncodedToken>
|
||||
{
|
||||
Tokens = tokens,
|
||||
CharsConsumed = input.Length,
|
||||
NormalizedText = null,
|
||||
};
|
||||
}
|
||||
|
||||
public override OperationStatus Decode(System.Collections.Generic.IEnumerable<int> ids, System.Span<char> destination, out int idsConsumed, out int charsWritten)
|
||||
{
|
||||
idsConsumed = 0;
|
||||
charsWritten = 0;
|
||||
return OperationStatus.Done;
|
||||
}
|
||||
}
|
||||
}
|
||||
+191
@@ -0,0 +1,191 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Compaction;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.UnitTests.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// Contains tests for the <see cref="PipelineCompactionStrategy"/> class.
|
||||
/// </summary>
|
||||
public class PipelineCompactionStrategyTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task CompactAsyncExecutesAllStrategiesInOrderAsync()
|
||||
{
|
||||
// Arrange
|
||||
List<string> executionOrder = [];
|
||||
TestCompactionStrategy strategy1 = new(
|
||||
_ =>
|
||||
{
|
||||
executionOrder.Add("first");
|
||||
return false;
|
||||
});
|
||||
|
||||
TestCompactionStrategy strategy2 = new(
|
||||
_ =>
|
||||
{
|
||||
executionOrder.Add("second");
|
||||
return false;
|
||||
});
|
||||
|
||||
PipelineCompactionStrategy pipeline = new(strategy1, strategy2);
|
||||
MessageIndex groups = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]);
|
||||
|
||||
// Act
|
||||
await pipeline.CompactAsync(groups);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(["first", "second"], executionOrder);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncReturnsFalseWhenNoStrategyCompactsAsync()
|
||||
{
|
||||
// Arrange
|
||||
TestCompactionStrategy strategy1 = new(_ => false);
|
||||
|
||||
PipelineCompactionStrategy pipeline = new(strategy1);
|
||||
MessageIndex groups = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]);
|
||||
|
||||
// Act
|
||||
bool result = await pipeline.CompactAsync(groups);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncReturnsTrueWhenAnyStrategyCompactsAsync()
|
||||
{
|
||||
// Arrange
|
||||
TestCompactionStrategy strategy1 = new(_ => false);
|
||||
TestCompactionStrategy strategy2 = new(_ => true);
|
||||
|
||||
PipelineCompactionStrategy pipeline = new(strategy1, strategy2);
|
||||
MessageIndex groups = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]);
|
||||
|
||||
// Act
|
||||
bool result = await pipeline.CompactAsync(groups);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncContinuesAfterFirstCompactionAsync()
|
||||
{
|
||||
// Arrange
|
||||
TestCompactionStrategy strategy1 = new(_ => true);
|
||||
TestCompactionStrategy strategy2 = new(_ => false);
|
||||
|
||||
PipelineCompactionStrategy pipeline = new(strategy1, strategy2);
|
||||
MessageIndex groups = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]);
|
||||
|
||||
// Act
|
||||
await pipeline.CompactAsync(groups);
|
||||
|
||||
// Assert — both strategies were called
|
||||
Assert.Equal(1, strategy1.ApplyCallCount);
|
||||
Assert.Equal(1, strategy2.ApplyCallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncComposesStrategiesEndToEndAsync()
|
||||
{
|
||||
// Arrange — pipeline: first exclude oldest 2 non-system groups, then exclude 2 more
|
||||
static void ExcludeOldest2(MessageIndex index)
|
||||
{
|
||||
int excluded = 0;
|
||||
foreach (MessageGroup group in index.Groups)
|
||||
{
|
||||
if (!group.IsExcluded && group.Kind != MessageGroupKind.System && excluded < 2)
|
||||
{
|
||||
group.IsExcluded = true;
|
||||
excluded++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TestCompactionStrategy phase1 = new(
|
||||
index =>
|
||||
{
|
||||
ExcludeOldest2(index);
|
||||
return true;
|
||||
});
|
||||
|
||||
TestCompactionStrategy phase2 = new(
|
||||
index =>
|
||||
{
|
||||
ExcludeOldest2(index);
|
||||
return true;
|
||||
});
|
||||
|
||||
PipelineCompactionStrategy pipeline = new(phase1, phase2);
|
||||
|
||||
MessageIndex groups = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.System, "You are helpful."),
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, "A1"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
new ChatMessage(ChatRole.Assistant, "A2"),
|
||||
new ChatMessage(ChatRole.User, "Q3"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
bool result = await pipeline.CompactAsync(groups);
|
||||
|
||||
// Assert — system is preserved, phase1 excluded Q1+A1, phase2 excluded Q2+A2 → System + Q3
|
||||
Assert.True(result);
|
||||
Assert.Equal(2, groups.IncludedGroupCount);
|
||||
|
||||
List<ChatMessage> included = [.. groups.GetIncludedMessages()];
|
||||
Assert.Equal(2, included.Count);
|
||||
Assert.Equal("You are helpful.", included[0].Text);
|
||||
Assert.Equal("Q3", included[1].Text);
|
||||
|
||||
Assert.Equal(1, phase1.ApplyCallCount);
|
||||
Assert.Equal(1, phase2.ApplyCallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncEmptyPipelineReturnsFalseAsync()
|
||||
{
|
||||
// Arrange
|
||||
PipelineCompactionStrategy pipeline = new(new List<CompactionStrategy>());
|
||||
MessageIndex groups = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]);
|
||||
|
||||
// Act
|
||||
bool result = await pipeline.CompactAsync(groups);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A simple test implementation of <see cref="CompactionStrategy"/> that delegates to a synchronous callback.
|
||||
/// </summary>
|
||||
private sealed class TestCompactionStrategy : CompactionStrategy
|
||||
{
|
||||
private readonly Func<MessageIndex, bool> _applyFunc;
|
||||
|
||||
public TestCompactionStrategy(Func<MessageIndex, bool> applyFunc)
|
||||
: base(CompactionTriggers.Always)
|
||||
{
|
||||
this._applyFunc = applyFunc;
|
||||
}
|
||||
|
||||
public int ApplyCallCount { get; private set; }
|
||||
|
||||
protected override Task<bool> ApplyCompactionAsync(MessageIndex index, CancellationToken cancellationToken)
|
||||
{
|
||||
this.ApplyCallCount++;
|
||||
return Task.FromResult(this._applyFunc(index));
|
||||
}
|
||||
}
|
||||
}
|
||||
+250
@@ -0,0 +1,250 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Compaction;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.UnitTests.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// Contains tests for the <see cref="SlidingWindowCompactionStrategy"/> class.
|
||||
/// </summary>
|
||||
public class SlidingWindowCompactionStrategyTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task CompactAsyncBelowMaxTurnsReturnsFalseAsync()
|
||||
{
|
||||
// Arrange — trigger requires > 3 turns, conversation has 2
|
||||
SlidingWindowCompactionStrategy strategy = new(CompactionTriggers.TurnsExceed(3));
|
||||
MessageIndex groups = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, "A1"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
new ChatMessage(ChatRole.Assistant, "A2"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(groups);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncExceedsMaxTurnsExcludesOldestTurnsAsync()
|
||||
{
|
||||
// Arrange — trigger on > 2 turns, conversation has 3
|
||||
SlidingWindowCompactionStrategy strategy = new(CompactionTriggers.TurnsExceed(2));
|
||||
MessageIndex groups = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, "A1"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
new ChatMessage(ChatRole.Assistant, "A2"),
|
||||
new ChatMessage(ChatRole.User, "Q3"),
|
||||
new ChatMessage(ChatRole.Assistant, "A3"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(groups);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
// Turn 1 (Q1 + A1) should be excluded
|
||||
Assert.True(groups.Groups[0].IsExcluded);
|
||||
Assert.True(groups.Groups[1].IsExcluded);
|
||||
// Turn 2 and 3 should remain
|
||||
Assert.False(groups.Groups[2].IsExcluded);
|
||||
Assert.False(groups.Groups[3].IsExcluded);
|
||||
Assert.False(groups.Groups[4].IsExcluded);
|
||||
Assert.False(groups.Groups[5].IsExcluded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncPreservesSystemMessagesAsync()
|
||||
{
|
||||
// Arrange — trigger on > 1 turn
|
||||
SlidingWindowCompactionStrategy strategy = new(CompactionTriggers.TurnsExceed(1));
|
||||
MessageIndex groups = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.System, "You are helpful."),
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, "A1"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(groups);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
Assert.False(groups.Groups[0].IsExcluded); // System preserved
|
||||
Assert.True(groups.Groups[1].IsExcluded); // Turn 1 excluded
|
||||
Assert.True(groups.Groups[2].IsExcluded); // Turn 1 response excluded
|
||||
Assert.False(groups.Groups[3].IsExcluded); // Turn 2 kept
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncPreservesToolCallGroupsInKeptTurnsAsync()
|
||||
{
|
||||
// Arrange — trigger on > 1 turn
|
||||
SlidingWindowCompactionStrategy strategy = new(CompactionTriggers.TurnsExceed(1));
|
||||
MessageIndex groups = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, "A1"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", "search")]),
|
||||
new ChatMessage(ChatRole.Tool, "Results"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(groups);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
// Turn 1 excluded
|
||||
Assert.True(groups.Groups[0].IsExcluded);
|
||||
Assert.True(groups.Groups[1].IsExcluded);
|
||||
// Turn 2 kept (user + tool call group)
|
||||
Assert.False(groups.Groups[2].IsExcluded);
|
||||
Assert.False(groups.Groups[3].IsExcluded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncTriggerNotMetReturnsFalseAsync()
|
||||
{
|
||||
// Arrange — trigger requires > 99 turns
|
||||
SlidingWindowCompactionStrategy strategy = new(CompactionTriggers.TurnsExceed(99));
|
||||
|
||||
MessageIndex groups = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
new ChatMessage(ChatRole.User, "Q3"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(groups);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncIncludedMessagesContainOnlyKeptTurnsAsync()
|
||||
{
|
||||
// Arrange — trigger on > 1 turn
|
||||
SlidingWindowCompactionStrategy strategy = new(CompactionTriggers.TurnsExceed(1));
|
||||
MessageIndex groups = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.System, "System"),
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, "A1"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
new ChatMessage(ChatRole.Assistant, "A2"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
await strategy.CompactAsync(groups);
|
||||
|
||||
// Assert
|
||||
List<ChatMessage> included = [.. groups.GetIncludedMessages()];
|
||||
Assert.Equal(3, included.Count);
|
||||
Assert.Equal("System", included[0].Text);
|
||||
Assert.Equal("Q2", included[1].Text);
|
||||
Assert.Equal("A2", included[2].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncCustomTargetStopsExcludingEarlyAsync()
|
||||
{
|
||||
// Arrange — trigger on > 1 turn, custom target stops after removing 1 turn
|
||||
int removeCount = 0;
|
||||
bool TargetAfterOne(MessageIndex _) => ++removeCount >= 1;
|
||||
|
||||
SlidingWindowCompactionStrategy strategy = new(
|
||||
CompactionTriggers.TurnsExceed(1),
|
||||
minimumPreserved: 0,
|
||||
target: TargetAfterOne);
|
||||
|
||||
MessageIndex index = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, "A1"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
new ChatMessage(ChatRole.Assistant, "A2"),
|
||||
new ChatMessage(ChatRole.User, "Q3"),
|
||||
new ChatMessage(ChatRole.Assistant, "A3"),
|
||||
new ChatMessage(ChatRole.User, "Q4"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(index);
|
||||
|
||||
// Assert — only turn 1 excluded (target stopped after 1 removal)
|
||||
Assert.True(result);
|
||||
Assert.True(index.Groups[0].IsExcluded); // Q1 (turn 1)
|
||||
Assert.True(index.Groups[1].IsExcluded); // A1 (turn 1)
|
||||
Assert.False(index.Groups[2].IsExcluded); // Q2 (turn 2) — kept
|
||||
Assert.False(index.Groups[3].IsExcluded); // A2 (turn 2)
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncMinimumPreservedStopsCompactionAsync()
|
||||
{
|
||||
// Arrange — always trigger with never-satisfied target, but MinimumPreserved = 2 is hard floor
|
||||
SlidingWindowCompactionStrategy strategy = new(
|
||||
CompactionTriggers.TurnsExceed(1),
|
||||
minimumPreserved: 2,
|
||||
target: _ => false);
|
||||
|
||||
MessageIndex index = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, "A1"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
new ChatMessage(ChatRole.Assistant, "A2"),
|
||||
new ChatMessage(ChatRole.User, "Q3"),
|
||||
new ChatMessage(ChatRole.Assistant, "A3"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(index);
|
||||
|
||||
// Assert — target never says stop, but MinimumPreserved=2 prevents removing the last 2 groups
|
||||
Assert.True(result);
|
||||
Assert.Equal(2, index.IncludedGroupCount);
|
||||
// Last 2 non-system groups must be preserved
|
||||
Assert.False(index.Groups[4].IsExcluded); // Q3
|
||||
Assert.False(index.Groups[5].IsExcluded); // A3
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncSkipsExcludedAndSystemGroupsInEnumerationAsync()
|
||||
{
|
||||
// Arrange — includes system and pre-excluded groups that must be skipped
|
||||
SlidingWindowCompactionStrategy strategy = new(
|
||||
CompactionTriggers.TurnsExceed(1),
|
||||
minimumPreserved: 0);
|
||||
|
||||
MessageIndex index = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.System, "System prompt"),
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, "A1"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
]);
|
||||
// Pre-exclude one group
|
||||
index.Groups[1].IsExcluded = true;
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(index);
|
||||
|
||||
// Assert — system preserved, pre-excluded skipped
|
||||
Assert.True(result);
|
||||
Assert.False(index.Groups[0].IsExcluded); // System preserved
|
||||
}
|
||||
}
|
||||
+410
@@ -0,0 +1,410 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Compaction;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.UnitTests.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// Contains tests for the <see cref="SummarizationCompactionStrategy"/> class.
|
||||
/// </summary>
|
||||
public class SummarizationCompactionStrategyTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a mock <see cref="IChatClient"/> that returns the specified summary text.
|
||||
/// </summary>
|
||||
private static IChatClient CreateMockChatClient(string summaryText = "Summary of conversation.")
|
||||
{
|
||||
Mock<IChatClient> mock = new();
|
||||
mock.Setup(c => c.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ChatResponse([new ChatMessage(ChatRole.Assistant, summaryText)]));
|
||||
return mock.Object;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncTriggerNotMetReturnsFalseAsync()
|
||||
{
|
||||
// Arrange — trigger requires > 100000 tokens
|
||||
SummarizationCompactionStrategy strategy = new(
|
||||
CreateMockChatClient(),
|
||||
CompactionTriggers.TokensExceed(100000),
|
||||
minimumPreserved: 1);
|
||||
|
||||
MessageIndex index = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Hello"),
|
||||
new ChatMessage(ChatRole.Assistant, "Hi!"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(index);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
Assert.Equal(2, index.IncludedGroupCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncSummarizesOldGroupsAsync()
|
||||
{
|
||||
// Arrange — always trigger, preserve 1 recent group
|
||||
SummarizationCompactionStrategy strategy = new(
|
||||
CreateMockChatClient("Key facts from earlier."),
|
||||
CompactionTriggers.Always,
|
||||
minimumPreserved: 1);
|
||||
|
||||
MessageIndex index = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "First question"),
|
||||
new ChatMessage(ChatRole.Assistant, "First answer"),
|
||||
new ChatMessage(ChatRole.User, "Second question"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(index);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
|
||||
List<ChatMessage> included = [.. index.GetIncludedMessages()];
|
||||
|
||||
// Should have: summary + preserved recent group (Second question)
|
||||
Assert.Equal(2, included.Count);
|
||||
Assert.Contains("[Summary]", included[0].Text);
|
||||
Assert.Contains("Key facts from earlier.", included[0].Text);
|
||||
Assert.Equal("Second question", included[1].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncPreservesSystemMessagesAsync()
|
||||
{
|
||||
// Arrange
|
||||
SummarizationCompactionStrategy strategy = new(
|
||||
CreateMockChatClient(),
|
||||
CompactionTriggers.Always,
|
||||
minimumPreserved: 1);
|
||||
|
||||
MessageIndex index = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.System, "You are helpful."),
|
||||
new ChatMessage(ChatRole.User, "Old question"),
|
||||
new ChatMessage(ChatRole.Assistant, "Old answer"),
|
||||
new ChatMessage(ChatRole.User, "Recent question"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
await strategy.CompactAsync(index);
|
||||
|
||||
// Assert
|
||||
List<ChatMessage> included = [.. index.GetIncludedMessages()];
|
||||
|
||||
Assert.Equal("You are helpful.", included[0].Text);
|
||||
Assert.Equal(ChatRole.System, included[0].Role);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncInsertsSummaryGroupAtCorrectPositionAsync()
|
||||
{
|
||||
// Arrange
|
||||
SummarizationCompactionStrategy strategy = new(
|
||||
CreateMockChatClient("Summary text."),
|
||||
CompactionTriggers.Always,
|
||||
minimumPreserved: 1);
|
||||
|
||||
MessageIndex index = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.System, "System prompt."),
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, "A1"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
await strategy.CompactAsync(index);
|
||||
|
||||
// Assert — summary should be inserted after system, before preserved group
|
||||
MessageGroup summaryGroup = index.Groups.First(g => g.Kind == MessageGroupKind.Summary);
|
||||
Assert.NotNull(summaryGroup);
|
||||
Assert.Contains("[Summary]", summaryGroup.Messages[0].Text);
|
||||
Assert.True(summaryGroup.Messages[0].AdditionalProperties!.ContainsKey(MessageGroup.SummaryPropertyKey));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncHandlesEmptyLlmResponseAsync()
|
||||
{
|
||||
// Arrange — LLM returns whitespace
|
||||
SummarizationCompactionStrategy strategy = new(
|
||||
CreateMockChatClient(" "),
|
||||
CompactionTriggers.Always,
|
||||
minimumPreserved: 1);
|
||||
|
||||
MessageIndex index = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
await strategy.CompactAsync(index);
|
||||
|
||||
// Assert — should use fallback text
|
||||
List<ChatMessage> included = [.. index.GetIncludedMessages()];
|
||||
Assert.Contains("[Summary unavailable]", included[0].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncNothingToSummarizeReturnsFalseAsync()
|
||||
{
|
||||
// Arrange — preserve 5 but only 2 non-system groups
|
||||
SummarizationCompactionStrategy strategy = new(
|
||||
CreateMockChatClient(),
|
||||
CompactionTriggers.Always,
|
||||
minimumPreserved: 5);
|
||||
|
||||
MessageIndex index = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Hello"),
|
||||
new ChatMessage(ChatRole.Assistant, "Hi!"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(index);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncUsesCustomPromptAsync()
|
||||
{
|
||||
// Arrange — capture the messages sent to the chat client
|
||||
List<ChatMessage>? capturedMessages = null;
|
||||
Mock<IChatClient> mockClient = new();
|
||||
mockClient.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, "Custom summary.")]));
|
||||
|
||||
const string CustomPrompt = "Summarize in bullet points only.";
|
||||
SummarizationCompactionStrategy strategy = new(
|
||||
mockClient.Object,
|
||||
CompactionTriggers.Always,
|
||||
minimumPreserved: 1,
|
||||
summarizationPrompt: CustomPrompt);
|
||||
|
||||
MessageIndex index = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
await strategy.CompactAsync(index);
|
||||
|
||||
// Assert — the custom prompt should be the first message sent to the LLM
|
||||
Assert.NotNull(capturedMessages);
|
||||
Assert.Equal(CustomPrompt, capturedMessages![0].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncSetsExcludeReasonAsync()
|
||||
{
|
||||
// Arrange
|
||||
SummarizationCompactionStrategy strategy = new(
|
||||
CreateMockChatClient(),
|
||||
CompactionTriggers.Always,
|
||||
minimumPreserved: 1);
|
||||
|
||||
MessageIndex index = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Old"),
|
||||
new ChatMessage(ChatRole.User, "New"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
await strategy.CompactAsync(index);
|
||||
|
||||
// Assert
|
||||
MessageGroup excluded = index.Groups.First(g => g.IsExcluded);
|
||||
Assert.NotNull(excluded.ExcludeReason);
|
||||
Assert.Contains("SummarizationCompactionStrategy", excluded.ExcludeReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncTargetStopsMarkingEarlyAsync()
|
||||
{
|
||||
// Arrange — 4 non-system groups, preserve 1, target met after 1 exclusion
|
||||
int exclusionCount = 0;
|
||||
bool TargetAfterOne(MessageIndex _) => ++exclusionCount >= 1;
|
||||
|
||||
SummarizationCompactionStrategy strategy = new(
|
||||
CreateMockChatClient("Partial summary."),
|
||||
CompactionTriggers.Always,
|
||||
minimumPreserved: 1,
|
||||
target: TargetAfterOne);
|
||||
|
||||
MessageIndex index = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, "A1"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
new ChatMessage(ChatRole.User, "Q3"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
await strategy.CompactAsync(index);
|
||||
|
||||
// Assert — only 1 group should have been summarized (target met after first exclusion)
|
||||
int excludedCount = index.Groups.Count(g => g.IsExcluded);
|
||||
Assert.Equal(1, excludedCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncPreservesMultipleRecentGroupsAsync()
|
||||
{
|
||||
// Arrange — preserve 2
|
||||
SummarizationCompactionStrategy strategy = new(
|
||||
CreateMockChatClient("Summary."),
|
||||
CompactionTriggers.Always,
|
||||
minimumPreserved: 2);
|
||||
|
||||
MessageIndex index = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, "A1"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
new ChatMessage(ChatRole.Assistant, "A2"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
await strategy.CompactAsync(index);
|
||||
|
||||
// Assert — 2 oldest excluded, 2 newest preserved + 1 summary inserted
|
||||
List<ChatMessage> included = [.. index.GetIncludedMessages()];
|
||||
Assert.Equal(3, included.Count); // summary + Q2 + A2
|
||||
Assert.Contains("[Summary]", included[0].Text);
|
||||
Assert.Equal("Q2", included[1].Text);
|
||||
Assert.Equal("A2", included[2].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncWithSystemBetweenSummarizableGroupsAsync()
|
||||
{
|
||||
// Arrange — system group between user/assistant groups to exercise skip logic in loop
|
||||
IChatClient mockClient = CreateMockChatClient("[Summary]");
|
||||
SummarizationCompactionStrategy strategy = new(
|
||||
mockClient,
|
||||
CompactionTriggers.Always,
|
||||
minimumPreserved: 1);
|
||||
|
||||
MessageIndex index = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.System, "System note"),
|
||||
new ChatMessage(ChatRole.Assistant, "A1"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(index);
|
||||
|
||||
// Assert — summary inserted at 0, system group shifted to index 2
|
||||
Assert.True(result);
|
||||
Assert.Equal(MessageGroupKind.Summary, index.Groups[0].Kind);
|
||||
Assert.Equal(MessageGroupKind.System, index.Groups[2].Kind);
|
||||
Assert.False(index.Groups[2].IsExcluded); // System never excluded
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncMaxSummarizableBoundsLoopExitAsync()
|
||||
{
|
||||
// Arrange — large MinimumPreserved so maxSummarizable is small, target never stops
|
||||
IChatClient mockClient = CreateMockChatClient("[Summary]");
|
||||
SummarizationCompactionStrategy strategy = new(
|
||||
mockClient,
|
||||
CompactionTriggers.Always,
|
||||
minimumPreserved: 3,
|
||||
target: _ => false);
|
||||
|
||||
MessageIndex index = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, "A1"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
new ChatMessage(ChatRole.Assistant, "A2"),
|
||||
new ChatMessage(ChatRole.User, "Q3"),
|
||||
new ChatMessage(ChatRole.Assistant, "A3"),
|
||||
]);
|
||||
|
||||
// Act — should only summarize 6-3 = 3 groups (not all 6)
|
||||
bool result = await strategy.CompactAsync(index);
|
||||
|
||||
// Assert — 3 preserved + 1 summary = 4 included
|
||||
Assert.True(result);
|
||||
Assert.Equal(4, index.IncludedGroupCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncWithPreExcludedGroupAsync()
|
||||
{
|
||||
// Arrange — pre-exclude a group so the count and loop both must skip it
|
||||
IChatClient mockClient = CreateMockChatClient("[Summary]");
|
||||
SummarizationCompactionStrategy strategy = new(
|
||||
mockClient,
|
||||
CompactionTriggers.Always,
|
||||
minimumPreserved: 1);
|
||||
|
||||
MessageIndex index = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, "A1"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
new ChatMessage(ChatRole.Assistant, "A2"),
|
||||
]);
|
||||
index.Groups[0].IsExcluded = true; // Pre-exclude Q1
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(index);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
Assert.True(index.Groups[0].IsExcluded); // Still excluded
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncWithEmptyTextMessageInGroupAsync()
|
||||
{
|
||||
// Arrange — a message with null text (FunctionCallContent) in a summarized group
|
||||
IChatClient mockClient = CreateMockChatClient("[Summary]");
|
||||
SummarizationCompactionStrategy strategy = new(
|
||||
mockClient,
|
||||
CompactionTriggers.Always,
|
||||
minimumPreserved: 1);
|
||||
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "fn")]),
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, "A1"),
|
||||
];
|
||||
|
||||
MessageIndex index = MessageIndex.Create(messages);
|
||||
|
||||
// Act — the tool-call group's message has null text
|
||||
bool result = await strategy.CompactAsync(index);
|
||||
|
||||
// Assert — compaction succeeded despite null text
|
||||
Assert.True(result);
|
||||
}
|
||||
}
|
||||
+262
@@ -0,0 +1,262 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Compaction;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.UnitTests.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// Contains tests for the <see cref="ToolResultCompactionStrategy"/> class.
|
||||
/// </summary>
|
||||
public class ToolResultCompactionStrategyTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task CompactAsyncTriggerNotMetReturnsFalseAsync()
|
||||
{
|
||||
// Arrange — trigger requires > 1000 tokens
|
||||
ToolResultCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(1000));
|
||||
|
||||
ChatMessage toolCall = new(ChatRole.Assistant, [new FunctionCallContent("call1", "get_weather")]);
|
||||
ChatMessage toolResult = new(ChatRole.Tool, "Sunny");
|
||||
|
||||
MessageIndex groups = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "What's the weather?"),
|
||||
toolCall,
|
||||
toolResult,
|
||||
]);
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(groups);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncCollapsesOldToolGroupsAsync()
|
||||
{
|
||||
// Arrange — always trigger
|
||||
ToolResultCompactionStrategy strategy = new(
|
||||
trigger: _ => true,
|
||||
minimumPreserved: 1);
|
||||
|
||||
MessageIndex groups = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", "get_weather")]),
|
||||
new ChatMessage(ChatRole.Tool, "Sunny and 72°F"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(groups);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
|
||||
List<ChatMessage> included = [.. groups.GetIncludedMessages()];
|
||||
// Q1 + collapsed tool summary + Q2
|
||||
Assert.Equal(3, included.Count);
|
||||
Assert.Equal("Q1", included[0].Text);
|
||||
Assert.Contains("[Tool calls: get_weather]", included[1].Text);
|
||||
Assert.Equal("Q2", included[2].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncPreservesRecentToolGroupsAsync()
|
||||
{
|
||||
// Arrange — protect 2 recent non-system groups (the tool group + Q2)
|
||||
ToolResultCompactionStrategy strategy = new(
|
||||
trigger: _ => true,
|
||||
minimumPreserved: 3);
|
||||
|
||||
MessageIndex groups = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", "search")]),
|
||||
new ChatMessage(ChatRole.Tool, "Results"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(groups);
|
||||
|
||||
// Assert — all groups are in the protected window, nothing to collapse
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncPreservesSystemMessagesAsync()
|
||||
{
|
||||
// Arrange
|
||||
ToolResultCompactionStrategy strategy = new(
|
||||
trigger: _ => true,
|
||||
minimumPreserved: 1);
|
||||
|
||||
MessageIndex groups = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.System, "You are helpful."),
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", "fn")]),
|
||||
new ChatMessage(ChatRole.Tool, "result"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
await strategy.CompactAsync(groups);
|
||||
|
||||
// Assert
|
||||
List<ChatMessage> included = [.. groups.GetIncludedMessages()];
|
||||
Assert.Equal("You are helpful.", included[0].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncExtractsMultipleToolNamesAsync()
|
||||
{
|
||||
// Arrange — assistant calls two tools
|
||||
ToolResultCompactionStrategy strategy = new(
|
||||
trigger: _ => true,
|
||||
minimumPreserved: 1);
|
||||
|
||||
ChatMessage multiToolCall = new(ChatRole.Assistant,
|
||||
[
|
||||
new FunctionCallContent("c1", "get_weather"),
|
||||
new FunctionCallContent("c2", "search_docs"),
|
||||
]);
|
||||
|
||||
MessageIndex groups = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
multiToolCall,
|
||||
new ChatMessage(ChatRole.Tool, "Sunny"),
|
||||
new ChatMessage(ChatRole.Tool, "Found 3 docs"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
await strategy.CompactAsync(groups);
|
||||
|
||||
// Assert
|
||||
List<ChatMessage> included = [.. groups.GetIncludedMessages()];
|
||||
string collapsed = included[1].Text!;
|
||||
Assert.Contains("get_weather", collapsed);
|
||||
Assert.Contains("search_docs", collapsed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncNoToolGroupsReturnsFalseAsync()
|
||||
{
|
||||
// Arrange — trigger fires but no tool groups to collapse
|
||||
ToolResultCompactionStrategy strategy = new(
|
||||
trigger: _ => true,
|
||||
minimumPreserved: 0);
|
||||
|
||||
MessageIndex groups = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Hello"),
|
||||
new ChatMessage(ChatRole.Assistant, "Hi!"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(groups);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncCompoundTriggerRequiresTokensAndToolCallsAsync()
|
||||
{
|
||||
// Arrange — compound: tokens > 0 AND has tool calls
|
||||
ToolResultCompactionStrategy strategy = new(
|
||||
CompactionTriggers.All(
|
||||
CompactionTriggers.TokensExceed(0),
|
||||
CompactionTriggers.HasToolCalls()),
|
||||
minimumPreserved: 1);
|
||||
|
||||
MessageIndex groups = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "fn")]),
|
||||
new ChatMessage(ChatRole.Tool, "result"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(groups);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncTargetStopsCollapsingEarlyAsync()
|
||||
{
|
||||
// Arrange — 2 tool groups, target met after first collapse
|
||||
int collapseCount = 0;
|
||||
CompactionTrigger targetAfterOne = _ => ++collapseCount >= 1;
|
||||
|
||||
ToolResultCompactionStrategy strategy = new(
|
||||
trigger: _ => true,
|
||||
minimumPreserved: 1,
|
||||
target: targetAfterOne);
|
||||
|
||||
MessageIndex index = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "fn1")]),
|
||||
new ChatMessage(ChatRole.Tool, "result1"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c2", "fn2")]),
|
||||
new ChatMessage(ChatRole.Tool, "result2"),
|
||||
new ChatMessage(ChatRole.User, "Q3"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(index);
|
||||
|
||||
// Assert — only first tool group collapsed, second left intact
|
||||
Assert.True(result);
|
||||
|
||||
// Count collapsed tool groups (excluded with ToolCall kind)
|
||||
int collapsedToolGroups = 0;
|
||||
foreach (MessageGroup group in index.Groups)
|
||||
{
|
||||
if (group.IsExcluded && group.Kind == MessageGroupKind.ToolCall)
|
||||
{
|
||||
collapsedToolGroups++;
|
||||
}
|
||||
}
|
||||
|
||||
Assert.Equal(1, collapsedToolGroups);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncSkipsPreExcludedAndSystemGroupsAsync()
|
||||
{
|
||||
// Arrange — pre-excluded and system groups in the enumeration
|
||||
ToolResultCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreserved: 0);
|
||||
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new ChatMessage(ChatRole.System, "System prompt"),
|
||||
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "fn")]),
|
||||
new ChatMessage(ChatRole.Tool, "Result 1"),
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
];
|
||||
|
||||
MessageIndex index = MessageIndex.Create(messages);
|
||||
// Pre-exclude the user group
|
||||
index.Groups[index.Groups.Count - 1].IsExcluded = true;
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(index);
|
||||
|
||||
// Assert — system never excluded, pre-excluded skipped
|
||||
Assert.True(result);
|
||||
Assert.False(index.Groups[0].IsExcluded); // System stays
|
||||
}
|
||||
}
|
||||
+328
@@ -0,0 +1,328 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Compaction;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.UnitTests.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// Contains tests for the <see cref="TruncationCompactionStrategy"/> class.
|
||||
/// </summary>
|
||||
public class TruncationCompactionStrategyTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task CompactAsyncAlwaysTriggerCompactsToPreserveRecentAsync()
|
||||
{
|
||||
// Arrange — always-trigger means always compact
|
||||
TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreserved: 1);
|
||||
MessageIndex groups = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "First"),
|
||||
new ChatMessage(ChatRole.Assistant, "Response 1"),
|
||||
new ChatMessage(ChatRole.User, "Second"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(groups);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
Assert.Equal(1, groups.Groups.Count(g => !g.IsExcluded));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncTriggerNotMetReturnsFalseAsync()
|
||||
{
|
||||
// Arrange — trigger requires > 1000 tokens, conversation is tiny
|
||||
TruncationCompactionStrategy strategy = new(
|
||||
minimumPreserved: 1,
|
||||
trigger: CompactionTriggers.TokensExceed(1000));
|
||||
|
||||
MessageIndex groups = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Hello"),
|
||||
new ChatMessage(ChatRole.Assistant, "Hi!"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(groups);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
Assert.Equal(2, groups.IncludedGroupCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncTriggerMetExcludesOldestGroupsAsync()
|
||||
{
|
||||
// Arrange — trigger on groups > 2
|
||||
TruncationCompactionStrategy strategy = new(
|
||||
minimumPreserved: 1,
|
||||
trigger: CompactionTriggers.GroupsExceed(2));
|
||||
|
||||
MessageIndex groups = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "First"),
|
||||
new ChatMessage(ChatRole.Assistant, "Response 1"),
|
||||
new ChatMessage(ChatRole.User, "Second"),
|
||||
new ChatMessage(ChatRole.Assistant, "Response 2"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(groups);
|
||||
|
||||
// Assert — incremental: excludes until GroupsExceed(2) is no longer met → 2 groups remain
|
||||
Assert.True(result);
|
||||
Assert.Equal(2, groups.IncludedGroupCount);
|
||||
// Oldest 2 excluded, newest 2 kept
|
||||
Assert.True(groups.Groups[0].IsExcluded);
|
||||
Assert.True(groups.Groups[1].IsExcluded);
|
||||
Assert.False(groups.Groups[2].IsExcluded);
|
||||
Assert.False(groups.Groups[3].IsExcluded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncPreservesSystemMessagesAsync()
|
||||
{
|
||||
// Arrange
|
||||
TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreserved: 1);
|
||||
MessageIndex groups = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.System, "You are helpful."),
|
||||
new ChatMessage(ChatRole.User, "First"),
|
||||
new ChatMessage(ChatRole.Assistant, "Response 1"),
|
||||
new ChatMessage(ChatRole.User, "Second"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(groups);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
// System message should be preserved
|
||||
Assert.False(groups.Groups[0].IsExcluded);
|
||||
Assert.Equal(MessageGroupKind.System, groups.Groups[0].Kind);
|
||||
// Oldest non-system groups excluded
|
||||
Assert.True(groups.Groups[1].IsExcluded);
|
||||
Assert.True(groups.Groups[2].IsExcluded);
|
||||
// Most recent kept
|
||||
Assert.False(groups.Groups[3].IsExcluded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncPreservesToolCallGroupAtomicityAsync()
|
||||
{
|
||||
// Arrange
|
||||
TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreserved: 1);
|
||||
|
||||
ChatMessage assistantToolCall = new(ChatRole.Assistant, [new FunctionCallContent("call1", "get_weather")]);
|
||||
ChatMessage toolResult = new(ChatRole.Tool, "Sunny");
|
||||
ChatMessage finalResponse = new(ChatRole.User, "Thanks!");
|
||||
|
||||
MessageIndex groups = MessageIndex.Create([assistantToolCall, toolResult, finalResponse]);
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(groups);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
// Tool call group should be excluded as one atomic unit
|
||||
Assert.True(groups.Groups[0].IsExcluded);
|
||||
Assert.Equal(MessageGroupKind.ToolCall, groups.Groups[0].Kind);
|
||||
Assert.Equal(2, groups.Groups[0].Messages.Count);
|
||||
Assert.False(groups.Groups[1].IsExcluded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncSetsExcludeReasonAsync()
|
||||
{
|
||||
// Arrange
|
||||
TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreserved: 1);
|
||||
MessageIndex groups = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Old"),
|
||||
new ChatMessage(ChatRole.User, "New"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
await strategy.CompactAsync(groups);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(groups.Groups[0].ExcludeReason);
|
||||
Assert.Contains("TruncationCompactionStrategy", groups.Groups[0].ExcludeReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncSkipsAlreadyExcludedGroupsAsync()
|
||||
{
|
||||
// Arrange
|
||||
TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreserved: 1);
|
||||
MessageIndex groups = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Already excluded"),
|
||||
new ChatMessage(ChatRole.User, "Included 1"),
|
||||
new ChatMessage(ChatRole.User, "Included 2"),
|
||||
]);
|
||||
groups.Groups[0].IsExcluded = true;
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(groups);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
Assert.True(groups.Groups[0].IsExcluded); // was already excluded
|
||||
Assert.True(groups.Groups[1].IsExcluded); // newly excluded
|
||||
Assert.False(groups.Groups[2].IsExcluded); // kept
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncMinimumPreservedKeepsMultipleAsync()
|
||||
{
|
||||
// Arrange — keep 2 most recent
|
||||
TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreserved: 2);
|
||||
MessageIndex groups = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, "A1"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
new ChatMessage(ChatRole.Assistant, "A2"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(groups);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
Assert.True(groups.Groups[0].IsExcluded);
|
||||
Assert.True(groups.Groups[1].IsExcluded);
|
||||
Assert.False(groups.Groups[2].IsExcluded);
|
||||
Assert.False(groups.Groups[3].IsExcluded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncNothingToRemoveReturnsFalseAsync()
|
||||
{
|
||||
// Arrange — preserve 5 but only 2 groups
|
||||
TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreserved: 5);
|
||||
MessageIndex groups = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Hello"),
|
||||
new ChatMessage(ChatRole.Assistant, "Hi!"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(groups);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncCustomTargetStopsEarlyAsync()
|
||||
{
|
||||
// Arrange — always trigger, custom target stops after 1 exclusion
|
||||
int targetChecks = 0;
|
||||
bool TargetAfterOne(MessageIndex _) => ++targetChecks >= 1;
|
||||
|
||||
TruncationCompactionStrategy strategy = new(
|
||||
CompactionTriggers.Always,
|
||||
minimumPreserved: 1,
|
||||
target: TargetAfterOne);
|
||||
|
||||
MessageIndex groups = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, "A1"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
new ChatMessage(ChatRole.User, "Q3"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(groups);
|
||||
|
||||
// Assert — only 1 group excluded (target met after first)
|
||||
Assert.True(result);
|
||||
Assert.True(groups.Groups[0].IsExcluded);
|
||||
Assert.False(groups.Groups[1].IsExcluded);
|
||||
Assert.False(groups.Groups[2].IsExcluded);
|
||||
Assert.False(groups.Groups[3].IsExcluded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncIncrementalStopsAtTargetAsync()
|
||||
{
|
||||
// Arrange — trigger on groups > 2, target is default (inverse of trigger: groups <= 2)
|
||||
TruncationCompactionStrategy strategy = new(
|
||||
CompactionTriggers.GroupsExceed(2),
|
||||
minimumPreserved: 1);
|
||||
|
||||
MessageIndex groups = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, "A1"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
new ChatMessage(ChatRole.Assistant, "A2"),
|
||||
new ChatMessage(ChatRole.User, "Q3"),
|
||||
]);
|
||||
|
||||
// Act — 5 groups, trigger fires (5 > 2), compacts until groups <= 2
|
||||
bool result = await strategy.CompactAsync(groups);
|
||||
|
||||
// Assert — should stop at 2 included groups (not go all the way to 1)
|
||||
Assert.True(result);
|
||||
Assert.Equal(2, groups.IncludedGroupCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncLoopExitsWhenMaxRemovableReachedAsync()
|
||||
{
|
||||
// Arrange — target never stops (always false), so the loop must exit via removed >= maxRemovable
|
||||
TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreserved: 2, target: CompactionTriggers.Never);
|
||||
MessageIndex groups = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, "A1"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
new ChatMessage(ChatRole.Assistant, "A2"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(groups);
|
||||
|
||||
// Assert — only 2 removed (maxRemovable = 4 - 2 = 2), 2 preserved
|
||||
Assert.True(result);
|
||||
Assert.Equal(2, groups.IncludedGroupCount);
|
||||
Assert.True(groups.Groups[0].IsExcluded);
|
||||
Assert.True(groups.Groups[1].IsExcluded);
|
||||
Assert.False(groups.Groups[2].IsExcluded);
|
||||
Assert.False(groups.Groups[3].IsExcluded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncSkipsPreExcludedAndSystemGroupsAsync()
|
||||
{
|
||||
// Arrange — has excluded + system groups that the loop must skip
|
||||
TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreserved: 1);
|
||||
MessageIndex groups = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.System, "System"),
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, "A1"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
]);
|
||||
// Pre-exclude one group
|
||||
groups.Groups[1].IsExcluded = true;
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(groups);
|
||||
|
||||
// Assert — system preserved, pre-excluded skipped, A1 removed, Q2 preserved
|
||||
Assert.True(result);
|
||||
Assert.False(groups.Groups[0].IsExcluded); // System
|
||||
Assert.True(groups.Groups[1].IsExcluded); // Pre-excluded Q1
|
||||
Assert.True(groups.Groups[2].IsExcluded); // Newly excluded A1
|
||||
Assert.False(groups.Groups[3].IsExcluded); // Preserved Q2
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging" />
|
||||
<PackageReference Include="Microsoft.ML.Tokenizers" />
|
||||
<PackageReference Include="OpenTelemetry" />
|
||||
<PackageReference Include="OpenTelemetry.Exporter.InMemory" />
|
||||
<PackageReference Include="System.Linq.AsyncEnumerable" />
|
||||
|
||||
Reference in New Issue
Block a user