Checkpoint

This commit is contained in:
Chris Rickman
2026-03-04 23:31:18 -08:00
Unverified
parent 23cf75be3c
commit fcd60daed5
20 changed files with 703 additions and 506 deletions
+1
View File
@@ -56,6 +56,7 @@
<Project Path="samples/02-agents/Agents/Agent_Step15_DeepResearch/Agent_Step15_DeepResearch.csproj" />
<Project Path="samples/02-agents/Agents/Agent_Step16_Declarative/Agent_Step16_Declarative.csproj" />
<Project Path="samples/02-agents/Agents/Agent_Step17_AdditionalAIContext/Agent_Step17_AdditionalAIContext.csproj" />
<Project Path="samples/02-agents/Agents/Agent_Step18_CompactionPipeline/Agent_Step18_CompactionPipeline.csproj" />
</Folder>
<Folder Name="/Samples/02-agents/DeclarativeAgents/">
<Project Path="samples/02-agents/DeclarativeAgents/ChatClient/DeclarativeChatClientAgents.csproj" />
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.OpenAI" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,109 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample demonstrates how to use a ChatHistoryCompactionPipeline as the ChatReducer for an agent's
// in-memory chat history. The pipeline chains multiple compaction strategies from gentle to aggressive:
// 1. ToolResultCompactionStrategy - Collapses old tool-call groups into concise summaries
// 2. SummarizationCompactionStrategy - LLM-compresses older conversation spans
// 3. SlidingWindowCompactionStrategy - Keeps only the most recent N user turns
// 4. TruncationCompactionStrategy - Emergency token-budget backstop
using System.ComponentModel;
using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Compaction;
using Microsoft.Extensions.AI;
var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
AzureOpenAIClient openAIClient = new(new Uri(endpoint), new DefaultAzureCredential());
// Create a chat client for the agent and a separate one for the summarization strategy.
// Using the same model for simplicity; in production, use a smaller/cheaper model for summarization.
IChatClient agentChatClient = openAIClient.GetChatClient(deploymentName).AsIChatClient();
IChatClient summarizerChatClient = openAIClient.GetChatClient(deploymentName).AsIChatClient();
// Define a tool the agent can use, so we can see tool-result compaction in action.
[Description("Look up the current price of a product by name.")]
static string LookupPrice([Description("The product name to look up.")] string productName) =>
productName.ToUpperInvariant() switch
{
"LAPTOP" => "The laptop costs $999.99.",
"KEYBOARD" => "The keyboard costs $79.99.",
"MOUSE" => "The mouse costs $29.99.",
_ => $"Sorry, I don't have pricing for '{productName}'."
};
// Configure the compaction pipeline with one of each strategy, ordered least to most aggressive.
//const int MaxTokens = 512;
//const int MaxTurns = 4;
const int MaxGroups = 2;
PipelineCompactionStrategy compactionPipeline =
new(// 1. Gentle: collapse old tool-call groups into short summaries like "[Tool calls: LookupPrice]"
//new ToolResultCompactionStrategy(MaxTokens, preserveRecentGroups: 2),
// 2. Moderate: use an LLM to summarize older conversation spans into a concise message
new SummarizationCompactionStrategy(summarizerChatClient, MaxGroups)
// 3. Aggressive: keep only the last N user turns and their responses
//new SlidingWindowCompactionStrategy(MaxTurns),
// 4. Emergency: drop oldest groups until under the token budget
//new TruncationCompactionStrategy(MaxGroups)
);
// 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();
}
@@ -5,7 +5,6 @@ using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Compaction;
using Microsoft.Extensions.AI;
using Microsoft.Shared.Diagnostics;
@@ -270,35 +269,6 @@ public abstract class ChatHistoryProvider
protected virtual ValueTask StoreChatHistoryAsync(InvokedContext context, CancellationToken cancellationToken = default) =>
default;
/// <summary>
/// Compacts the messages in place using the specified compaction strategy before they are stored.
/// </summary>
/// <param name="messages">The messages to compact. This list is mutated in place.</param>
/// <param name="compactionStrategy">The compaction strategy to apply.</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.</returns>
/// <remarks>
/// <para>
/// This method organizes the messages into atomic <see cref="MessageGroup"/> units,
/// applies the compaction strategy, and replaces the contents of the list with the compacted result.
/// Tool call groups (assistant message + tool results) are treated as atomic units.
/// </para>
/// </remarks>
protected static async Task<bool> CompactMessagesAsync(List<ChatMessage> messages, ICompactionStrategy compactionStrategy, CancellationToken cancellationToken = default)
{
MessageGroups groups = MessageGroups.Create(messages);
bool compacted = await compactionStrategy.CompactAsync(groups, cancellationToken).ConfigureAwait(false);
if (compacted)
{
messages.Clear();
messages.AddRange(groups.GetIncludedMessages());
}
return compacted;
}
/// <summary>Asks the <see cref="ChatHistoryProvider"/> for an object of the specified type <paramref name="serviceType"/>.</summary>
/// <param name="serviceType">The type of object being requested.</param>
/// <param name="serviceKey">An optional key that can be used to help identify the target service.</param>
@@ -6,11 +6,11 @@ using System.Threading.Tasks;
namespace Microsoft.Agents.AI.Compaction;
/// <summary>
/// Defines a strategy for compacting a <see cref="MessageGroups"/> to reduce context size.
/// Defines a strategy for compacting a <see cref="MessageIndex"/> to reduce context size.
/// </summary>
/// <remarks>
/// <para>
/// Compaction strategies operate on <see cref="MessageGroups"/> instances, which organize messages
/// 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>
@@ -23,7 +23,7 @@ namespace Microsoft.Agents.AI.Compaction;
/// </list>
/// </para>
/// <para>
/// Multiple strategies can be composed by applying them sequentially to the same <see cref="MessageGroups"/>.
/// Multiple strategies can be composed by applying them sequentially to the same <see cref="MessageIndex"/>.
/// </para>
/// </remarks>
public interface ICompactionStrategy
@@ -34,5 +34,5 @@ public interface ICompactionStrategy
/// <param name="groups">The message group collection 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>
Task<bool> CompactAsync(MessageGroups groups, CancellationToken cancellationToken = default);
Task<bool> CompactAsync(MessageIndex groups, CancellationToken cancellationToken = default);
}
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Text.Json.Serialization;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Compaction;
@@ -21,8 +22,8 @@ namespace Microsoft.Agents.AI.Compaction;
/// </para>
/// <para>
/// Each group tracks its <see cref="MessageCount"/>, <see cref="ByteCount"/>, and <see cref="TokenCount"/>
/// so that <see cref="MessageGroups"/> can efficiently aggregate totals across all or only included groups.
/// These values are computed by <see cref="MessageGroups.Create"/> and passed into the constructor.
/// 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
@@ -32,7 +33,7 @@ public sealed class MessageGroup
/// </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="MessageGroups.Create"/>.
/// <see cref="MessageGroupKind.Summary"/> by <see cref="MessageIndex.Create"/>.
/// </remarks>
public static readonly string SummaryPropertyKey = "_is_summary";
@@ -47,6 +48,7 @@ public sealed class MessageGroup
/// 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;
@@ -97,7 +99,7 @@ public sealed class MessageGroup
/// </summary>
/// <remarks>
/// Excluded groups are preserved in the collection for diagnostics or storage purposes
/// but are not included when calling <see cref="MessageGroups.GetIncludedMessages"/>.
/// but are not included when calling <see cref="MessageIndex.GetIncludedMessages"/>.
/// </remarks>
public bool IsExcluded { get; set; }
@@ -13,7 +13,7 @@ namespace Microsoft.Agents.AI.Compaction;
/// </summary>
/// <remarks>
/// <para>
/// <see cref="MessageGroups"/> provides structural grouping of messages into logical units that
/// <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>
@@ -27,9 +27,16 @@ namespace Microsoft.Agents.AI.Compaction;
/// 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 MessageGroups
public sealed class MessageIndex
{
private int _currentTurn;
/// <summary>
/// Gets the list of message groups in this collection.
/// </summary>
@@ -41,25 +48,43 @@ public sealed class MessageGroups
public Tokenizer? Tokenizer { get; }
/// <summary>
/// Initializes a new instance of the <see cref="MessageGroups"/> class with the specified groups.
/// 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 MessageGroups(IList<MessageGroup> groups, Tokenizer? tokenizer = null)
public MessageIndex(IList<MessageGroup> groups, Tokenizer? tokenizer = null)
{
this.Groups = groups;
this.Tokenizer = tokenizer;
for (int index = groups.Count - 1; index >= 0; --index)
{
if (this.Groups[0].TurnIndex.HasValue)
{
this._currentTurn = this.Groups[0].TurnIndex!.Value;
break;
}
}
}
/// <summary>
/// Creates a <see cref="MessageGroups"/> from a flat list of <see cref="ChatMessage"/> instances.
/// 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="MessageGroups"/> with messages organized into logical groups.</returns>
/// <returns>A new <see cref="MessageIndex"/> with messages organized into logical groups.</returns>
/// <remarks>
/// The grouping algorithm:
/// <list type="bullet">
@@ -70,11 +95,61 @@ public sealed class MessageGroups
/// <item><description>Assistant messages without tool calls become <see cref="MessageGroupKind.AssistantText"/> groups.</description></item>
/// </list>
/// </remarks>
public static MessageGroups Create(IList<ChatMessage> messages, Tokenizer? tokenizer = null)
public static MessageIndex Create(IList<ChatMessage> messages, Tokenizer? tokenizer = null)
{
List<MessageGroup> groups = [];
int index = 0;
int currentTurn = 0;
MessageIndex instance = new(new List<MessageGroup>(), 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)
{
@@ -83,13 +158,13 @@ public sealed class MessageGroups
if (message.Role == ChatRole.System)
{
// System messages are not part of any turn
groups.Add(CreateGroup(MessageGroupKind.System, [message], tokenizer, turnIndex: null));
this.Groups.Add(CreateGroup(MessageGroupKind.System, [message], this.Tokenizer, turnIndex: null));
index++;
}
else if (message.Role == ChatRole.User)
{
currentTurn++;
groups.Add(CreateGroup(MessageGroupKind.User, [message], tokenizer, currentTurn));
this._currentTurn++;
this.Groups.Add(CreateGroup(MessageGroupKind.User, [message], this.Tokenizer, this._currentTurn));
index++;
}
else if (message.Role == ChatRole.Assistant && HasToolCalls(message))
@@ -104,21 +179,21 @@ public sealed class MessageGroups
index++;
}
groups.Add(CreateGroup(MessageGroupKind.ToolCall, groupMessages, tokenizer, currentTurn));
this.Groups.Add(CreateGroup(MessageGroupKind.ToolCall, groupMessages, this.Tokenizer, this._currentTurn));
}
else if (message.Role == ChatRole.Assistant && IsSummaryMessage(message))
{
groups.Add(CreateGroup(MessageGroupKind.Summary, [message], tokenizer, currentTurn));
this.Groups.Add(CreateGroup(MessageGroupKind.Summary, [message], this.Tokenizer, this._currentTurn));
index++;
}
else
{
groups.Add(CreateGroup(MessageGroupKind.AssistantText, [message], tokenizer, currentTurn));
this.Groups.Add(CreateGroup(MessageGroupKind.AssistantText, [message], this.Tokenizer, this._currentTurn));
index++;
}
}
return new MessageGroups(groups, tokenizer);
this.ProcessedMessageCount = messages.Count;
}
/// <summary>
@@ -9,7 +9,7 @@ namespace Microsoft.Agents.AI.Compaction;
/// <summary>
/// A compaction strategy that executes a sequential pipeline of <see cref="ICompactionStrategy"/> instances
/// against the same <see cref="MessageGroups"/>.
/// against the same <see cref="MessageIndex"/>.
/// </summary>
/// <remarks>
/// <para>
@@ -28,7 +28,8 @@ public sealed class PipelineCompactionStrategy : ICompactionStrategy
/// Initializes a new instance of the <see cref="PipelineCompactionStrategy"/> class.
/// </summary>
/// <param name="strategies">The ordered sequence of strategies to execute. Must not be empty.</param>
public PipelineCompactionStrategy(params IEnumerable<ICompactionStrategy> strategies)
///// <param name="cache">An optional cache for <see cref="MessageIndex"/> instances. When <see langword="null"/>, a default <see cref="InMemoryMessageIndexCache"/> is created.</param>
public PipelineCompactionStrategy(params IEnumerable<ICompactionStrategy> strategies/*, IMessageIndexCache? cache = null*/)
{
this.Strategies = [.. Throw.IfNull(strategies)];
}
@@ -58,7 +59,7 @@ public sealed class PipelineCompactionStrategy : ICompactionStrategy
public int? TargetIncludedGroupCount { get; set; }
/// <inheritdoc/>
public async Task<bool> CompactAsync(MessageGroups groups, CancellationToken cancellationToken = default)
public async Task<bool> CompactAsync(MessageIndex groups, CancellationToken cancellationToken = default)
{
bool anyCompacted = false;
@@ -6,7 +6,6 @@ using System.Linq;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Compaction;
using Microsoft.Extensions.AI;
using Microsoft.Shared.Diagnostics;
@@ -47,7 +46,6 @@ public sealed class InMemoryChatHistoryProvider : ChatHistoryProvider
options?.JsonSerializerOptions);
this.ChatReducer = options?.ChatReducer;
this.ReducerTriggerEvent = options?.ReducerTriggerEvent ?? InMemoryChatHistoryProviderOptions.ChatReducerTriggerEvent.BeforeMessagesRetrieval;
this.CompactionStrategy = options?.CompactionStrategy;
}
/// <inheritdoc />
@@ -63,11 +61,6 @@ public sealed class InMemoryChatHistoryProvider : ChatHistoryProvider
/// </summary>
public InMemoryChatHistoryProviderOptions.ChatReducerTriggerEvent ReducerTriggerEvent { get; }
/// <summary>
/// Gets the compaction strategy used to compact stored messages. If <see langword="null"/>, no compaction is applied.
/// </summary>
public ICompactionStrategy? CompactionStrategy { get; }
/// <summary>
/// Gets the chat messages stored for the specified session.
/// </summary>
@@ -84,20 +77,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;
@@ -106,46 +100,29 @@ 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 compaction strategy if configured (pre-write compaction)
if (this.CompactionStrategy is not null)
{
await CompactMessagesAsync(state.Messages, this.CompactionStrategy, cancellationToken).ConfigureAwait(false);
// Apply pre-write compaction strategy if configured
await this.CompactMessagesAsync(state, cancellationToken).ConfigureAwait(false);
}
}
/// <summary>
/// Compacts the stored messages for the specified session using the given or configured compaction strategy.
/// </summary>
/// <param name="session">The agent session whose stored messages should be compacted.</param>
/// <param name="compactionStrategy">
/// An optional compaction strategy to use. If <see langword="null"/>, the provider's configured
/// <see cref="CompactionStrategy"/> is used. If neither is available, an <see cref="InvalidOperationException"/> is thrown.
/// </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.</returns>
/// <exception cref="InvalidOperationException">No compaction strategy is configured or provided.</exception>
/// <remarks>
/// This method enables on-demand compaction of stored history, for example as a maintenance operation.
/// It reads the full stored history, applies the compaction strategy, and writes the compacted result back.
/// </remarks>
public async Task<bool> CompactStorageAsync(AgentSession? session, ICompactionStrategy? compactionStrategy = null, CancellationToken cancellationToken = default)
private async Task CompactMessagesAsync(State state, CancellationToken cancellationToken = default)
{
ICompactionStrategy strategy = compactionStrategy ?? this.CompactionStrategy
?? throw new InvalidOperationException("No compaction strategy is configured or provided.");
if (this.ChatReducer is not null)
{
// ChatReducer takes precedence, if configured
state.Messages = [.. await this.ChatReducer.ReduceAsync(state.Messages, cancellationToken).ConfigureAwait(false)];
return;
}
var state = this._sessionState.GetOrInitializeState(session);
return await CompactMessagesAsync(state.Messages, strategy, cancellationToken).ConfigureAwait(false);
// %%% TODO: CONSIDER COMPACTION
}
/// <summary>
@@ -74,21 +74,6 @@ public sealed class InMemoryChatHistoryProviderOptions
/// </value>
public Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>>? ProvideOutputMessageFilter { get; set; }
/// <summary>
/// Gets or sets an optional <see cref="ICompactionStrategy"/> to apply to stored messages after new messages are added.
/// </summary>
/// <remarks>
/// <para>
/// When set, this strategy is applied to the full stored message list after new messages have been appended.
/// This enables pre-write compaction to limit storage size.
/// </para>
/// <para>
/// The compaction strategy organizes messages into atomic groups (preserving tool-call/result pairings)
/// before applying the strategy logic. See <see cref="ICompactionStrategy"/> for details.
/// </para>
/// </remarks>
public ICompactionStrategy? CompactionStrategy { get; set; }
/// <summary>
/// Defines the events that can trigger a reducer in the <see cref="InMemoryChatHistoryProvider"/>.
/// </summary>
@@ -59,10 +59,6 @@ public sealed class ChatClientAgentOptions
/// The strategy organizes messages into atomic groups (preserving tool-call/result pairings)
/// before applying compaction logic. See <see cref="ICompactionStrategy"/> for details.
/// </para>
/// <para>
/// This is separate from the compaction strategy on <see cref="InMemoryChatHistoryProviderOptions.CompactionStrategy"/>,
/// which applies pre-write compaction before storing messages. Both can be used together.
/// </para>
/// </remarks>
public ICompactionStrategy? CompactionStrategy { get; set; }
@@ -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;
@@ -1,75 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Compaction;
using Microsoft.Extensions.AI;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
/// <summary>
/// A delegating <see cref="IChatClient"/> that applies an <see cref="ICompactionStrategy"/> 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 <see cref="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 ICompactionStrategy _compactionStrategy;
/// <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, ICompactionStrategy compactionStrategy)
: base(innerClient)
{
this._compactionStrategy = Throw.IfNull(compactionStrategy);
}
/// <inheritdoc/>
public override async Task<ChatResponse> GetResponseAsync(
IEnumerable<ChatMessage> messages,
ChatOptions? options = null,
CancellationToken cancellationToken = default)
{
List<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)
{
List<ChatMessage> compactedMessages = await this.ApplyCompactionAsync(messages, cancellationToken).ConfigureAwait(false);
await foreach (var update in base.GetStreamingResponseAsync(compactedMessages, options, cancellationToken).ConfigureAwait(false))
{
yield return update;
}
}
private async Task<List<ChatMessage>> ApplyCompactionAsync(IEnumerable<ChatMessage> messages, CancellationToken cancellationToken)
{
List<ChatMessage> messageList = messages as List<ChatMessage> ?? [.. messages];
MessageGroups groups = MessageGroups.Create(messageList);
bool compacted = await this._compactionStrategy.CompactAsync(groups, cancellationToken).ConfigureAwait(false);
return compacted ? [.. groups.GetIncludedMessages()] : messageList;
}
}
@@ -0,0 +1,133 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
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="ICompactionStrategy"/> 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 ICompactionStrategy _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, ICompactionStrategy 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 (var 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(ICompactionStrategy)) ?
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
bool wasCompacted = await this._compactionStrategy.CompactAsync(messageIndex, cancellationToken).ConfigureAwait(false);
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; } = [];
}
}
@@ -58,7 +58,7 @@ public sealed class SummarizationCompactionStrategy : ICompactionStrategy
public string SummarizationPrompt { get; }
/// <inheritdoc/>
public async Task<bool> CompactAsync(MessageGroups groups, CancellationToken cancellationToken = default)
public async Task<bool> CompactAsync(MessageIndex groups, CancellationToken cancellationToken = default)
{
int includedCount = groups.IncludedGroupCount;
if (includedCount <= this.MaxGroupsBeforeSummary)
@@ -44,7 +44,7 @@ public sealed class TruncationCompactionStrategy : ICompactionStrategy
public bool PreserveSystemMessages { get; }
/// <inheritdoc/>
public Task<bool> CompactAsync(MessageGroups groups, CancellationToken cancellationToken = default)
public Task<bool> CompactAsync(MessageIndex groups, CancellationToken cancellationToken = default)
{
int includedCount = groups.IncludedGroupCount;
if (includedCount <= this.MaxGroups)
@@ -1,268 +1,269 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Compaction;
using Microsoft.Extensions.AI;
using Moq;
// %%% SAVE - RE-ANALYZE
//using System.Collections.Generic;
//using System.Threading;
//using System.Threading.Tasks;
//using Microsoft.Agents.AI.Compaction;
//using Microsoft.Extensions.AI;
//using Moq;
namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction;
//namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction;
/// <summary>
/// Contains tests for the compaction integration with <see cref="InMemoryChatHistoryProvider"/>.
/// </summary>
public class InMemoryChatHistoryProviderCompactionTests
{
private static readonly AIAgent s_mockAgent = new Mock<AIAgent>().Object;
///// <summary>
///// Contains tests for the compaction integration with <see cref="InMemoryChatHistoryProvider"/>.
///// </summary>
//public class InMemoryChatHistoryProviderCompactionTests
//{
// private static readonly AIAgent s_mockAgent = new Mock<AIAgent>().Object;
private static AgentSession CreateMockSession() => new Mock<AgentSession>().Object;
// private static AgentSession CreateMockSession() => new Mock<AgentSession>().Object;
[Fact]
public void Constructor_SetsCompactionStrategy_FromOptions()
{
// Arrange
Mock<ICompactionStrategy> strategy = new();
// [Fact]
// public void Constructor_SetsCompactionStrategy_FromOptions()
// {
// // Arrange
// Mock<ICompactionStrategy> strategy = new();
// Act
InMemoryChatHistoryProvider provider = new(new InMemoryChatHistoryProviderOptions
{
CompactionStrategy = strategy.Object,
});
// // Act
// InMemoryChatHistoryProvider provider = new(new InMemoryChatHistoryProviderOptions
// {
// CompactionStrategy = strategy.Object,
// });
// Assert
Assert.Same(strategy.Object, provider.CompactionStrategy);
}
// // Assert
// Assert.Same(strategy.Object, provider.CompactionStrategy);
// }
[Fact]
public void Constructor_CompactionStrategyIsNull_ByDefault()
{
// Arrange & Act
InMemoryChatHistoryProvider provider = new();
// [Fact]
// public void Constructor_CompactionStrategyIsNull_ByDefault()
// {
// // Arrange & Act
// InMemoryChatHistoryProvider provider = new();
// Assert
Assert.Null(provider.CompactionStrategy);
}
// // Assert
// Assert.Null(provider.CompactionStrategy);
// }
[Fact]
public async Task StoreChatHistoryAsync_AppliesCompaction_WhenStrategyConfiguredAsync()
{
// Arrange — mock strategy that excludes the first included non-system group
Mock<ICompactionStrategy> mockStrategy = new();
mockStrategy.Setup(s => s.CompactAsync(It.IsAny<MessageGroups>(), It.IsAny<CancellationToken>()))
.Callback<MessageGroups, CancellationToken>((groups, _) =>
{
foreach (MessageGroup group in groups.Groups)
{
if (!group.IsExcluded && group.Kind != MessageGroupKind.System)
{
group.IsExcluded = true;
group.ExcludeReason = "Mock compaction";
break;
}
}
})
.ReturnsAsync(true);
// [Fact]
// public async Task StoreChatHistoryAsync_AppliesCompaction_WhenStrategyConfiguredAsync()
// {
// // Arrange — mock strategy that excludes the first included non-system group
// Mock<ICompactionStrategy> mockStrategy = new();
// mockStrategy.Setup(s => s.CompactAsync(It.IsAny<MessageIndex>(), It.IsAny<CancellationToken>()))
// .Callback<MessageIndex, CancellationToken>((groups, _) =>
// {
// foreach (MessageGroup group in groups.Groups)
// {
// if (!group.IsExcluded && group.Kind != MessageGroupKind.System)
// {
// group.IsExcluded = true;
// group.ExcludeReason = "Mock compaction";
// break;
// }
// }
// })
// .ReturnsAsync(true);
InMemoryChatHistoryProvider provider = new(new InMemoryChatHistoryProviderOptions
{
CompactionStrategy = mockStrategy.Object,
});
// InMemoryChatHistoryProvider provider = new(new InMemoryChatHistoryProviderOptions
// {
// CompactionStrategy = mockStrategy.Object,
// });
AgentSession session = CreateMockSession();
// AgentSession session = CreateMockSession();
// Pre-populate with some messages
List<ChatMessage> existingMessages =
[
new ChatMessage(ChatRole.User, "First"),
new ChatMessage(ChatRole.Assistant, "Response 1"),
];
provider.SetMessages(session, existingMessages);
// // Pre-populate with some messages
// List<ChatMessage> existingMessages =
// [
// new ChatMessage(ChatRole.User, "First"),
// new ChatMessage(ChatRole.Assistant, "Response 1"),
// ];
// provider.SetMessages(session, existingMessages);
// Invoke the store flow with additional messages
List<ChatMessage> requestMessages =
[
new ChatMessage(ChatRole.User, "Second"),
];
List<ChatMessage> responseMessages =
[
new ChatMessage(ChatRole.Assistant, "Response 2"),
];
// // Invoke the store flow with additional messages
// List<ChatMessage> requestMessages =
// [
// new ChatMessage(ChatRole.User, "Second"),
// ];
// List<ChatMessage> responseMessages =
// [
// new ChatMessage(ChatRole.Assistant, "Response 2"),
// ];
ChatHistoryProvider.InvokedContext context = new(s_mockAgent, session, requestMessages, responseMessages);
// ChatHistoryProvider.InvokedContext context = new(s_mockAgent, session, requestMessages, responseMessages);
// Act
await provider.InvokedAsync(context);
// // Act
// await provider.InvokedAsync(context);
// Assert - compaction should have removed one group
List<ChatMessage> storedMessages = provider.GetMessages(session);
Assert.Equal(3, storedMessages.Count);
mockStrategy.Verify(s => s.CompactAsync(It.IsAny<MessageGroups>(), It.IsAny<CancellationToken>()), Times.Once);
}
// // Assert - compaction should have removed one group
// List<ChatMessage> storedMessages = provider.GetMessages(session);
// Assert.Equal(3, storedMessages.Count);
// mockStrategy.Verify(s => s.CompactAsync(It.IsAny<MessageIndex>(), It.IsAny<CancellationToken>()), Times.Once);
// }
[Fact]
public async Task StoreChatHistoryAsync_DoesNotCompact_WhenNoStrategyAsync()
{
// Arrange
InMemoryChatHistoryProvider provider = new();
AgentSession session = CreateMockSession();
// [Fact]
// public async Task StoreChatHistoryAsync_DoesNotCompact_WhenNoStrategyAsync()
// {
// // Arrange
// InMemoryChatHistoryProvider provider = new();
// AgentSession session = CreateMockSession();
List<ChatMessage> requestMessages =
[
new ChatMessage(ChatRole.User, "Hello"),
];
List<ChatMessage> responseMessages =
[
new ChatMessage(ChatRole.Assistant, "Hi!"),
];
// List<ChatMessage> requestMessages =
// [
// new ChatMessage(ChatRole.User, "Hello"),
// ];
// List<ChatMessage> responseMessages =
// [
// new ChatMessage(ChatRole.Assistant, "Hi!"),
// ];
ChatHistoryProvider.InvokedContext context = new(s_mockAgent, session, requestMessages, responseMessages);
// ChatHistoryProvider.InvokedContext context = new(s_mockAgent, session, requestMessages, responseMessages);
// Act
await provider.InvokedAsync(context);
// // Act
// await provider.InvokedAsync(context);
// Assert - all messages should be stored
List<ChatMessage> storedMessages = provider.GetMessages(session);
Assert.Equal(2, storedMessages.Count);
}
// // Assert - all messages should be stored
// List<ChatMessage> storedMessages = provider.GetMessages(session);
// Assert.Equal(2, storedMessages.Count);
// }
[Fact]
public async Task CompactStorageAsync_CompactsStoredMessagesAsync()
{
// Arrange — mock strategy that excludes the two oldest non-system groups
Mock<ICompactionStrategy> mockStrategy = new();
mockStrategy.Setup(s => s.CompactAsync(It.IsAny<MessageGroups>(), It.IsAny<CancellationToken>()))
.Callback<MessageGroups, CancellationToken>((groups, _) =>
{
int excluded = 0;
foreach (MessageGroup group in groups.Groups)
{
if (!group.IsExcluded && group.Kind != MessageGroupKind.System && excluded < 2)
{
group.IsExcluded = true;
excluded++;
}
}
})
.ReturnsAsync(true);
// [Fact]
// public async Task CompactStorageAsync_CompactsStoredMessagesAsync()
// {
// // Arrange — mock strategy that excludes the two oldest non-system groups
// Mock<ICompactionStrategy> mockStrategy = new();
// mockStrategy.Setup(s => s.CompactAsync(It.IsAny<MessageIndex>(), It.IsAny<CancellationToken>()))
// .Callback<MessageIndex, CancellationToken>((groups, _) =>
// {
// int excluded = 0;
// foreach (MessageGroup group in groups.Groups)
// {
// if (!group.IsExcluded && group.Kind != MessageGroupKind.System && excluded < 2)
// {
// group.IsExcluded = true;
// excluded++;
// }
// }
// })
// .ReturnsAsync(true);
InMemoryChatHistoryProvider provider = new(new InMemoryChatHistoryProviderOptions
{
CompactionStrategy = mockStrategy.Object,
});
// InMemoryChatHistoryProvider provider = new(new InMemoryChatHistoryProviderOptions
// {
// CompactionStrategy = mockStrategy.Object,
// });
AgentSession session = CreateMockSession();
provider.SetMessages(session,
[
new ChatMessage(ChatRole.User, "First"),
new ChatMessage(ChatRole.Assistant, "Response 1"),
new ChatMessage(ChatRole.User, "Second"),
new ChatMessage(ChatRole.Assistant, "Response 2"),
]);
// AgentSession session = CreateMockSession();
// provider.SetMessages(session,
// [
// 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 provider.CompactStorageAsync(session);
// // Act
// bool result = await provider.CompactStorageAsync(session);
// Assert
Assert.True(result);
List<ChatMessage> messages = provider.GetMessages(session);
Assert.Equal(2, messages.Count);
mockStrategy.Verify(s => s.CompactAsync(It.IsAny<MessageGroups>(), It.IsAny<CancellationToken>()), Times.Once);
}
// // Assert
// Assert.True(result);
// List<ChatMessage> messages = provider.GetMessages(session);
// Assert.Equal(2, messages.Count);
// mockStrategy.Verify(s => s.CompactAsync(It.IsAny<MessageIndex>(), It.IsAny<CancellationToken>()), Times.Once);
// }
[Fact]
public async Task CompactStorageAsync_UsesProvidedStrategy_OverDefaultAsync()
{
// Arrange
Mock<ICompactionStrategy> defaultStrategy = new();
Mock<ICompactionStrategy> overrideStrategy = new();
// [Fact]
// public async Task CompactStorageAsync_UsesProvidedStrategy_OverDefaultAsync()
// {
// // Arrange
// Mock<ICompactionStrategy> defaultStrategy = new();
// Mock<ICompactionStrategy> overrideStrategy = new();
overrideStrategy.Setup(s => s.CompactAsync(It.IsAny<MessageGroups>(), It.IsAny<CancellationToken>()))
.Callback<MessageGroups, CancellationToken>((groups, _) =>
{
// Exclude all but the last group
for (int i = 0; i < groups.Groups.Count - 1; i++)
{
groups.Groups[i].IsExcluded = true;
}
})
.ReturnsAsync(true);
// overrideStrategy.Setup(s => s.CompactAsync(It.IsAny<MessageIndex>(), It.IsAny<CancellationToken>()))
// .Callback<MessageIndex, CancellationToken>((groups, _) =>
// {
// // Exclude all but the last group
// for (int i = 0; i < groups.Groups.Count - 1; i++)
// {
// groups.Groups[i].IsExcluded = true;
// }
// })
// .ReturnsAsync(true);
InMemoryChatHistoryProvider provider = new(new InMemoryChatHistoryProviderOptions
{
CompactionStrategy = defaultStrategy.Object,
});
// InMemoryChatHistoryProvider provider = new(new InMemoryChatHistoryProviderOptions
// {
// CompactionStrategy = defaultStrategy.Object,
// });
AgentSession session = CreateMockSession();
provider.SetMessages(session,
[
new ChatMessage(ChatRole.User, "First"),
new ChatMessage(ChatRole.User, "Second"),
new ChatMessage(ChatRole.User, "Third"),
]);
// AgentSession session = CreateMockSession();
// provider.SetMessages(session,
// [
// new ChatMessage(ChatRole.User, "First"),
// new ChatMessage(ChatRole.User, "Second"),
// new ChatMessage(ChatRole.User, "Third"),
// ]);
// Act
bool result = await provider.CompactStorageAsync(session, overrideStrategy.Object);
// // Act
// bool result = await provider.CompactStorageAsync(session, overrideStrategy.Object);
// Assert
Assert.True(result);
List<ChatMessage> messages = provider.GetMessages(session);
Assert.Single(messages);
Assert.Equal("Third", messages[0].Text);
// // Assert
// Assert.True(result);
// List<ChatMessage> messages = provider.GetMessages(session);
// Assert.Single(messages);
// Assert.Equal("Third", messages[0].Text);
// Verify the override was used, not the default
overrideStrategy.Verify(s => s.CompactAsync(It.IsAny<MessageGroups>(), It.IsAny<CancellationToken>()), Times.Once);
defaultStrategy.Verify(s => s.CompactAsync(It.IsAny<MessageGroups>(), It.IsAny<CancellationToken>()), Times.Never);
}
// // Verify the override was used, not the default
// overrideStrategy.Verify(s => s.CompactAsync(It.IsAny<MessageIndex>(), It.IsAny<CancellationToken>()), Times.Once);
// defaultStrategy.Verify(s => s.CompactAsync(It.IsAny<MessageIndex>(), It.IsAny<CancellationToken>()), Times.Never);
// }
[Fact]
public async Task CompactStorageAsync_Throws_WhenNoStrategyAvailableAsync()
{
// Arrange
InMemoryChatHistoryProvider provider = new();
AgentSession session = CreateMockSession();
// [Fact]
// public async Task CompactStorageAsync_Throws_WhenNoStrategyAvailableAsync()
// {
// // Arrange
// InMemoryChatHistoryProvider provider = new();
// AgentSession session = CreateMockSession();
// Act & Assert
await Assert.ThrowsAsync<System.InvalidOperationException>(
() => provider.CompactStorageAsync(session));
}
// // Act & Assert
// await Assert.ThrowsAsync<System.InvalidOperationException>(
// () => provider.CompactStorageAsync(session));
// }
[Fact]
public async Task CompactStorageAsync_WithCustomStrategy_AppliesCustomLogicAsync()
{
// Arrange
Mock<ICompactionStrategy> mockStrategy = new();
mockStrategy.Setup(s => s.CompactAsync(It.IsAny<MessageGroups>(), It.IsAny<CancellationToken>()))
.Callback<MessageGroups, CancellationToken>((groups, _) =>
{
// Exclude all user groups
foreach (MessageGroup group in groups.Groups)
{
if (group.Kind == MessageGroupKind.User)
{
group.IsExcluded = true;
}
}
})
.ReturnsAsync(true);
// [Fact]
// public async Task CompactStorageAsync_WithCustomStrategy_AppliesCustomLogicAsync()
// {
// // Arrange
// Mock<ICompactionStrategy> mockStrategy = new();
// mockStrategy.Setup(s => s.CompactAsync(It.IsAny<MessageIndex>(), It.IsAny<CancellationToken>()))
// .Callback<MessageIndex, CancellationToken>((groups, _) =>
// {
// // Exclude all user groups
// foreach (MessageGroup group in groups.Groups)
// {
// if (group.Kind == MessageGroupKind.User)
// {
// group.IsExcluded = true;
// }
// }
// })
// .ReturnsAsync(true);
InMemoryChatHistoryProvider provider = new();
AgentSession session = CreateMockSession();
provider.SetMessages(session,
[
new ChatMessage(ChatRole.System, "System"),
new ChatMessage(ChatRole.User, "User message"),
new ChatMessage(ChatRole.Assistant, "Response"),
]);
// InMemoryChatHistoryProvider provider = new();
// AgentSession session = CreateMockSession();
// provider.SetMessages(session,
// [
// new ChatMessage(ChatRole.System, "System"),
// new ChatMessage(ChatRole.User, "User message"),
// new ChatMessage(ChatRole.Assistant, "Response"),
// ]);
// Act
bool result = await provider.CompactStorageAsync(session, mockStrategy.Object);
// // Act
// bool result = await provider.CompactStorageAsync(session, mockStrategy.Object);
// Assert
Assert.True(result);
List<ChatMessage> messages = provider.GetMessages(session);
Assert.Equal(2, messages.Count);
Assert.Equal(ChatRole.System, messages[0].Role);
Assert.Equal(ChatRole.Assistant, messages[1].Role);
}
}
// // Assert
// Assert.True(result);
// List<ChatMessage> messages = provider.GetMessages(session);
// Assert.Equal(2, messages.Count);
// Assert.Equal(ChatRole.System, messages[0].Role);
// Assert.Equal(ChatRole.Assistant, messages[1].Role);
// }
//}
@@ -1,4 +1,4 @@
// Copyright (c) Microsoft. All rights reserved.
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using Microsoft.Agents.AI.Compaction;
@@ -7,9 +7,9 @@ using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction;
/// <summary>
/// Contains tests for the <see cref="MessageGroups"/> class.
/// Contains tests for the <see cref="MessageIndex"/> class.
/// </summary>
public class MessageGroupsTests
public class MessageIndexTests
{
[Fact]
public void Create_EmptyList_ReturnsEmptyGroups()
@@ -18,7 +18,7 @@ public class MessageGroupsTests
List<ChatMessage> messages = [];
// Act
MessageGroups groups = MessageGroups.Create(messages);
MessageIndex groups = MessageIndex.Create(messages);
// Assert
Assert.Empty(groups.Groups);
@@ -34,7 +34,7 @@ public class MessageGroupsTests
];
// Act
MessageGroups groups = MessageGroups.Create(messages);
MessageIndex groups = MessageIndex.Create(messages);
// Assert
Assert.Single(groups.Groups);
@@ -52,7 +52,7 @@ public class MessageGroupsTests
];
// Act
MessageGroups groups = MessageGroups.Create(messages);
MessageIndex groups = MessageIndex.Create(messages);
// Assert
Assert.Single(groups.Groups);
@@ -69,7 +69,7 @@ public class MessageGroupsTests
];
// Act
MessageGroups groups = MessageGroups.Create(messages);
MessageIndex groups = MessageIndex.Create(messages);
// Assert
Assert.Single(groups.Groups);
@@ -86,7 +86,7 @@ public class MessageGroupsTests
List<ChatMessage> messages = [assistantMessage, toolResult];
// Act
MessageGroups groups = MessageGroups.Create(messages);
MessageIndex groups = MessageIndex.Create(messages);
// Assert
Assert.Single(groups.Groups);
@@ -109,7 +109,7 @@ public class MessageGroupsTests
List<ChatMessage> messages = [systemMsg, userMsg, assistantToolCall, toolResult, assistantText];
// Act
MessageGroups groups = MessageGroups.Create(messages);
MessageIndex groups = MessageIndex.Create(messages);
// Assert
Assert.Equal(4, groups.Groups.Count);
@@ -134,7 +134,7 @@ public class MessageGroupsTests
List<ChatMessage> messages = [assistantToolCall, toolResult1, toolResult2];
// Act
MessageGroups groups = MessageGroups.Create(messages);
MessageIndex groups = MessageIndex.Create(messages);
// Assert
Assert.Single(groups.Groups);
@@ -150,7 +150,7 @@ public class MessageGroupsTests
ChatMessage msg2 = new(ChatRole.Assistant, "Response");
ChatMessage msg3 = new(ChatRole.User, "Second");
MessageGroups groups = MessageGroups.Create([msg1, msg2, msg3]);
MessageIndex groups = MessageIndex.Create([msg1, msg2, msg3]);
groups.Groups[1].IsExcluded = true;
// Act
@@ -169,7 +169,7 @@ public class MessageGroupsTests
ChatMessage msg1 = new(ChatRole.User, "First");
ChatMessage msg2 = new(ChatRole.Assistant, "Response");
MessageGroups groups = MessageGroups.Create([msg1, msg2]);
MessageIndex groups = MessageIndex.Create([msg1, msg2]);
groups.Groups[0].IsExcluded = true;
// Act
@@ -183,7 +183,7 @@ public class MessageGroupsTests
public void IncludedGroupCount_ReflectsExclusions()
{
// Arrange
MessageGroups groups = MessageGroups.Create(
MessageIndex groups = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "A"),
new ChatMessage(ChatRole.Assistant, "B"),
@@ -207,7 +207,7 @@ public class MessageGroupsTests
List<ChatMessage> messages = [summaryMessage];
// Act
MessageGroups groups = MessageGroups.Create(messages);
MessageIndex groups = MessageIndex.Create(messages);
// Assert
Assert.Single(groups.Groups);
@@ -227,7 +227,7 @@ public class MessageGroupsTests
List<ChatMessage> messages = [systemMsg, summaryMsg, userMsg];
// Act
MessageGroups groups = MessageGroups.Create(messages);
MessageIndex groups = MessageIndex.Create(messages);
// Assert
Assert.Equal(3, groups.Groups.Count);
@@ -264,7 +264,7 @@ public class MessageGroupsTests
public void Create_ComputesByteCount_Utf8()
{
// Arrange — "Hello" is 5 UTF-8 bytes
MessageGroups groups = MessageGroups.Create([new ChatMessage(ChatRole.User, "Hello")]);
MessageIndex groups = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]);
// Assert
Assert.Equal(5, groups.Groups[0].ByteCount);
@@ -274,7 +274,7 @@ public class MessageGroupsTests
public void Create_ComputesByteCount_MultiByteChars()
{
// Arrange — "café" has a multi-byte 'é' (2 bytes in UTF-8) → 5 bytes total
MessageGroups groups = MessageGroups.Create([new ChatMessage(ChatRole.User, "café")]);
MessageIndex groups = MessageIndex.Create([new ChatMessage(ChatRole.User, "café")]);
// Assert
Assert.Equal(5, groups.Groups[0].ByteCount);
@@ -286,7 +286,7 @@ public class MessageGroupsTests
// 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");
MessageGroups groups = MessageGroups.Create([assistantMsg, toolResult]);
MessageIndex groups = MessageIndex.Create([assistantMsg, toolResult]);
// Assert — single ToolCall group with 2 messages
Assert.Single(groups.Groups);
@@ -298,7 +298,7 @@ public class MessageGroupsTests
public void Create_DefaultTokenCount_IsHeuristic()
{
// Arrange — "Hello world test data!" = 22 UTF-8 bytes → 22 / 4 = 5 estimated tokens
MessageGroups groups = MessageGroups.Create([new ChatMessage(ChatRole.User, "Hello world test data!")]);
MessageIndex groups = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello world test data!")]);
// Assert
Assert.Equal(22, groups.Groups[0].ByteCount);
@@ -311,7 +311,7 @@ public class MessageGroupsTests
// 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);
MessageGroups groups = MessageGroups.Create([msg, tool]);
MessageIndex groups = MessageIndex.Create([msg, tool]);
// Assert
Assert.Equal(2, groups.Groups[0].MessageCount);
@@ -323,7 +323,7 @@ public class MessageGroupsTests
public void TotalAggregates_SumAllGroups()
{
// Arrange
MessageGroups groups = MessageGroups.Create(
MessageIndex groups = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "AAAA"), // 4 bytes
new ChatMessage(ChatRole.Assistant, "BBBB"), // 4 bytes
@@ -342,7 +342,7 @@ public class MessageGroupsTests
public void IncludedAggregates_ExcludeMarkedGroups()
{
// Arrange
MessageGroups groups = MessageGroups.Create(
MessageIndex groups = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "AAAA"), // 4 bytes
new ChatMessage(ChatRole.Assistant, "BBBB"), // 4 bytes
@@ -369,7 +369,7 @@ public class MessageGroupsTests
ChatMessage assistantMsg = new(ChatRole.Assistant, [new FunctionCallContent("call1", "fn")]);
ChatMessage toolResult = new(ChatRole.Tool, "OK");
MessageGroups groups = MessageGroups.Create([assistantMsg, toolResult]);
MessageIndex groups = MessageIndex.Create([assistantMsg, toolResult]);
// Assert — single group with 2 messages
Assert.Single(groups.Groups);
@@ -383,7 +383,7 @@ public class MessageGroupsTests
public void Create_AssignsTurnIndices_SingleTurn()
{
// Arrange — System (no turn), User + Assistant = turn 1
MessageGroups groups = MessageGroups.Create(
MessageIndex groups = MessageIndex.Create(
[
new ChatMessage(ChatRole.System, "You are helpful."),
new ChatMessage(ChatRole.User, "Hello"),
@@ -402,7 +402,7 @@ public class MessageGroupsTests
public void Create_AssignsTurnIndices_MultiTurn()
{
// Arrange — 3 user turns
MessageGroups groups = MessageGroups.Create(
MessageIndex groups = MessageIndex.Create(
[
new ChatMessage(ChatRole.System, "System prompt."),
new ChatMessage(ChatRole.User, "Q1"),
@@ -429,7 +429,7 @@ public class MessageGroupsTests
ChatMessage assistantToolCall = new(ChatRole.Assistant, [new FunctionCallContent("call1", "get_weather")]);
ChatMessage toolResult = new(ChatRole.Tool, "Sunny");
MessageGroups groups = MessageGroups.Create(
MessageIndex groups = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "What's the weather?"),
assistantToolCall,
@@ -449,7 +449,7 @@ public class MessageGroupsTests
public void GetTurnGroups_ReturnsGroupsForSpecificTurn()
{
// Arrange
MessageGroups groups = MessageGroups.Create(
MessageIndex groups = MessageIndex.Create(
[
new ChatMessage(ChatRole.System, "System."),
new ChatMessage(ChatRole.User, "Q1"),
@@ -475,7 +475,7 @@ public class MessageGroupsTests
public void IncludedTurnCount_ReflectsExclusions()
{
// Arrange — 2 turns, exclude all groups in turn 1
MessageGroups groups = MessageGroups.Create(
MessageIndex groups = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
@@ -495,7 +495,7 @@ public class MessageGroupsTests
public void TotalTurnCount_ZeroWhenNoUserMessages()
{
// Arrange — only system messages
MessageGroups groups = MessageGroups.Create(
MessageIndex groups = MessageIndex.Create(
[
new ChatMessage(ChatRole.System, "System."),
]);
@@ -509,7 +509,7 @@ public class MessageGroupsTests
public void IncludedTurnCount_PartialExclusion_StillCountsTurn()
{
// Arrange — turn 1 has 2 groups, only one excluded
MessageGroups groups = MessageGroups.Create(
MessageIndex groups = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
@@ -1,4 +1,4 @@
// Copyright (c) Microsoft. All rights reserved.
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Threading;
@@ -20,17 +20,17 @@ public class PipelineCompactionStrategyTests
// Arrange
List<string> executionOrder = [];
Mock<ICompactionStrategy> strategy1 = new();
strategy1.Setup(s => s.CompactAsync(It.IsAny<MessageGroups>(), It.IsAny<CancellationToken>()))
strategy1.Setup(s => s.CompactAsync(It.IsAny<MessageIndex>(), It.IsAny<CancellationToken>()))
.Callback(() => executionOrder.Add("first"))
.ReturnsAsync(false);
Mock<ICompactionStrategy> strategy2 = new();
strategy2.Setup(s => s.CompactAsync(It.IsAny<MessageGroups>(), It.IsAny<CancellationToken>()))
strategy2.Setup(s => s.CompactAsync(It.IsAny<MessageIndex>(), It.IsAny<CancellationToken>()))
.Callback(() => executionOrder.Add("second"))
.ReturnsAsync(false);
PipelineCompactionStrategy pipeline = new(strategy1.Object, strategy2.Object);
MessageGroups groups = MessageGroups.Create([new ChatMessage(ChatRole.User, "Hello")]);
PipelineCompactionStrategy pipeline = new([strategy1.Object, strategy2.Object]);
MessageIndex groups = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]);
// Act
await pipeline.CompactAsync(groups);
@@ -44,11 +44,11 @@ public class PipelineCompactionStrategyTests
{
// Arrange
Mock<ICompactionStrategy> strategy1 = new();
strategy1.Setup(s => s.CompactAsync(It.IsAny<MessageGroups>(), It.IsAny<CancellationToken>()))
strategy1.Setup(s => s.CompactAsync(It.IsAny<MessageIndex>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(false);
PipelineCompactionStrategy pipeline = new(strategy1.Object);
MessageGroups groups = MessageGroups.Create([new ChatMessage(ChatRole.User, "Hello")]);
PipelineCompactionStrategy pipeline = new([strategy1.Object]);
MessageIndex groups = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]);
// Act
bool result = await pipeline.CompactAsync(groups);
@@ -62,15 +62,15 @@ public class PipelineCompactionStrategyTests
{
// Arrange
Mock<ICompactionStrategy> strategy1 = new();
strategy1.Setup(s => s.CompactAsync(It.IsAny<MessageGroups>(), It.IsAny<CancellationToken>()))
strategy1.Setup(s => s.CompactAsync(It.IsAny<MessageIndex>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(false);
Mock<ICompactionStrategy> strategy2 = new();
strategy2.Setup(s => s.CompactAsync(It.IsAny<MessageGroups>(), It.IsAny<CancellationToken>()))
strategy2.Setup(s => s.CompactAsync(It.IsAny<MessageIndex>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
PipelineCompactionStrategy pipeline = new(strategy1.Object, strategy2.Object);
MessageGroups groups = MessageGroups.Create([new ChatMessage(ChatRole.User, "Hello")]);
PipelineCompactionStrategy pipeline = new([strategy1.Object, strategy2.Object]);
MessageIndex groups = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]);
// Act
bool result = await pipeline.CompactAsync(groups);
@@ -84,22 +84,22 @@ public class PipelineCompactionStrategyTests
{
// Arrange
Mock<ICompactionStrategy> strategy1 = new();
strategy1.Setup(s => s.CompactAsync(It.IsAny<MessageGroups>(), It.IsAny<CancellationToken>()))
strategy1.Setup(s => s.CompactAsync(It.IsAny<MessageIndex>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
Mock<ICompactionStrategy> strategy2 = new();
strategy2.Setup(s => s.CompactAsync(It.IsAny<MessageGroups>(), It.IsAny<CancellationToken>()))
strategy2.Setup(s => s.CompactAsync(It.IsAny<MessageIndex>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(false);
PipelineCompactionStrategy pipeline = new(strategy1.Object, strategy2.Object);
MessageGroups groups = MessageGroups.Create([new ChatMessage(ChatRole.User, "Hello")]);
PipelineCompactionStrategy pipeline = new([strategy1.Object, strategy2.Object]);
MessageIndex groups = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]);
// Act
await pipeline.CompactAsync(groups);
// Assert — both strategies were called
strategy1.Verify(s => s.CompactAsync(It.IsAny<MessageGroups>(), It.IsAny<CancellationToken>()), Times.Once);
strategy2.Verify(s => s.CompactAsync(It.IsAny<MessageGroups>(), It.IsAny<CancellationToken>()), Times.Once);
strategy1.Verify(s => s.CompactAsync(It.IsAny<MessageIndex>(), It.IsAny<CancellationToken>()), Times.Once);
strategy2.Verify(s => s.CompactAsync(It.IsAny<MessageIndex>(), It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
@@ -107,8 +107,8 @@ public class PipelineCompactionStrategyTests
{
// Arrange — first strategy reduces to target
Mock<ICompactionStrategy> strategy1 = new();
strategy1.Setup(s => s.CompactAsync(It.IsAny<MessageGroups>(), It.IsAny<CancellationToken>()))
.Callback<MessageGroups, CancellationToken>((groups, _) =>
strategy1.Setup(s => s.CompactAsync(It.IsAny<MessageIndex>(), It.IsAny<CancellationToken>()))
.Callback<MessageIndex, CancellationToken>((groups, _) =>
{
// Exclude the first group to bring count down
groups.Groups[0].IsExcluded = true;
@@ -116,16 +116,16 @@ public class PipelineCompactionStrategyTests
.ReturnsAsync(true);
Mock<ICompactionStrategy> strategy2 = new();
strategy2.Setup(s => s.CompactAsync(It.IsAny<MessageGroups>(), It.IsAny<CancellationToken>()))
strategy2.Setup(s => s.CompactAsync(It.IsAny<MessageIndex>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(false);
PipelineCompactionStrategy pipeline = new(strategy1.Object, strategy2.Object)
PipelineCompactionStrategy pipeline = new([strategy1.Object, strategy2.Object])
{
EarlyStop = true,
TargetIncludedGroupCount = 2,
};
MessageGroups groups = MessageGroups.Create(
MessageIndex groups = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "First"),
new ChatMessage(ChatRole.Assistant, "Response"),
@@ -137,8 +137,8 @@ public class PipelineCompactionStrategyTests
// Assert — strategy2 should not have been called
Assert.True(result);
strategy1.Verify(s => s.CompactAsync(It.IsAny<MessageGroups>(), It.IsAny<CancellationToken>()), Times.Once);
strategy2.Verify(s => s.CompactAsync(It.IsAny<MessageGroups>(), It.IsAny<CancellationToken>()), Times.Never);
strategy1.Verify(s => s.CompactAsync(It.IsAny<MessageIndex>(), It.IsAny<CancellationToken>()), Times.Once);
strategy2.Verify(s => s.CompactAsync(It.IsAny<MessageIndex>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
@@ -146,20 +146,20 @@ public class PipelineCompactionStrategyTests
{
// Arrange — first strategy does NOT bring count to target
Mock<ICompactionStrategy> strategy1 = new();
strategy1.Setup(s => s.CompactAsync(It.IsAny<MessageGroups>(), It.IsAny<CancellationToken>()))
strategy1.Setup(s => s.CompactAsync(It.IsAny<MessageIndex>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(false);
Mock<ICompactionStrategy> strategy2 = new();
strategy2.Setup(s => s.CompactAsync(It.IsAny<MessageGroups>(), It.IsAny<CancellationToken>()))
strategy2.Setup(s => s.CompactAsync(It.IsAny<MessageIndex>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(false);
PipelineCompactionStrategy pipeline = new(strategy1.Object, strategy2.Object)
PipelineCompactionStrategy pipeline = new([strategy1.Object, strategy2.Object])
{
EarlyStop = true,
TargetIncludedGroupCount = 1,
};
MessageGroups groups = MessageGroups.Create(
MessageIndex groups = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "First"),
new ChatMessage(ChatRole.User, "Second"),
@@ -170,8 +170,8 @@ public class PipelineCompactionStrategyTests
await pipeline.CompactAsync(groups);
// Assert — both strategies were called since target was never reached
strategy1.Verify(s => s.CompactAsync(It.IsAny<MessageGroups>(), It.IsAny<CancellationToken>()), Times.Once);
strategy2.Verify(s => s.CompactAsync(It.IsAny<MessageGroups>(), It.IsAny<CancellationToken>()), Times.Once);
strategy1.Verify(s => s.CompactAsync(It.IsAny<MessageIndex>(), It.IsAny<CancellationToken>()), Times.Once);
strategy2.Verify(s => s.CompactAsync(It.IsAny<MessageIndex>(), It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
@@ -179,27 +179,27 @@ public class PipelineCompactionStrategyTests
{
// Arrange
Mock<ICompactionStrategy> strategy1 = new();
strategy1.Setup(s => s.CompactAsync(It.IsAny<MessageGroups>(), It.IsAny<CancellationToken>()))
strategy1.Setup(s => s.CompactAsync(It.IsAny<MessageIndex>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
Mock<ICompactionStrategy> strategy2 = new();
strategy2.Setup(s => s.CompactAsync(It.IsAny<MessageGroups>(), It.IsAny<CancellationToken>()))
strategy2.Setup(s => s.CompactAsync(It.IsAny<MessageIndex>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(false);
PipelineCompactionStrategy pipeline = new(strategy1.Object, strategy2.Object)
PipelineCompactionStrategy pipeline = new([strategy1.Object, strategy2.Object])
{
EarlyStop = true,
// TargetIncludedGroupCount is null
};
MessageGroups groups = MessageGroups.Create([new ChatMessage(ChatRole.User, "Hello")]);
MessageIndex groups = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]);
// Act
await pipeline.CompactAsync(groups);
// Assert — both strategies called because no target to check against
strategy1.Verify(s => s.CompactAsync(It.IsAny<MessageGroups>(), It.IsAny<CancellationToken>()), Times.Once);
strategy2.Verify(s => s.CompactAsync(It.IsAny<MessageGroups>(), It.IsAny<CancellationToken>()), Times.Once);
strategy1.Verify(s => s.CompactAsync(It.IsAny<MessageIndex>(), It.IsAny<CancellationToken>()), Times.Once);
strategy2.Verify(s => s.CompactAsync(It.IsAny<MessageIndex>(), It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
@@ -207,8 +207,8 @@ public class PipelineCompactionStrategyTests
{
// Arrange — pipeline: first exclude oldest 2 non-system groups, then exclude 2 more
Mock<ICompactionStrategy> phase1 = new();
phase1.Setup(s => s.CompactAsync(It.IsAny<MessageGroups>(), It.IsAny<CancellationToken>()))
.Callback<MessageGroups, CancellationToken>((groups, _) =>
phase1.Setup(s => s.CompactAsync(It.IsAny<MessageIndex>(), It.IsAny<CancellationToken>()))
.Callback<MessageIndex, CancellationToken>((groups, _) =>
{
int excluded = 0;
foreach (MessageGroup group in groups.Groups)
@@ -223,8 +223,8 @@ public class PipelineCompactionStrategyTests
.ReturnsAsync(true);
Mock<ICompactionStrategy> phase2 = new();
phase2.Setup(s => s.CompactAsync(It.IsAny<MessageGroups>(), It.IsAny<CancellationToken>()))
.Callback<MessageGroups, CancellationToken>((groups, _) =>
phase2.Setup(s => s.CompactAsync(It.IsAny<MessageIndex>(), It.IsAny<CancellationToken>()))
.Callback<MessageIndex, CancellationToken>((groups, _) =>
{
int excluded = 0;
foreach (MessageGroup group in groups.Groups)
@@ -238,9 +238,9 @@ public class PipelineCompactionStrategyTests
})
.ReturnsAsync(true);
PipelineCompactionStrategy pipeline = new(phase1.Object, phase2.Object);
PipelineCompactionStrategy pipeline = new([phase1.Object, phase2.Object]);
MessageGroups groups = MessageGroups.Create(
MessageIndex groups = MessageIndex.Create(
[
new ChatMessage(ChatRole.System, "You are helpful."),
new ChatMessage(ChatRole.User, "Q1"),
@@ -262,8 +262,8 @@ public class PipelineCompactionStrategyTests
Assert.Equal("You are helpful.", included[0].Text);
Assert.Equal("Q3", included[1].Text);
phase1.Verify(s => s.CompactAsync(It.IsAny<MessageGroups>(), It.IsAny<CancellationToken>()), Times.Once);
phase2.Verify(s => s.CompactAsync(It.IsAny<MessageGroups>(), It.IsAny<CancellationToken>()), Times.Once);
phase1.Verify(s => s.CompactAsync(It.IsAny<MessageIndex>(), It.IsAny<CancellationToken>()), Times.Once);
phase2.Verify(s => s.CompactAsync(It.IsAny<MessageIndex>(), It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
@@ -271,7 +271,7 @@ public class PipelineCompactionStrategyTests
{
// Arrange
PipelineCompactionStrategy pipeline = new(new List<ICompactionStrategy>());
MessageGroups groups = MessageGroups.Create([new ChatMessage(ChatRole.User, "Hello")]);
MessageIndex groups = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]);
// Act
bool result = await pipeline.CompactAsync(groups);
@@ -1,4 +1,4 @@
// Copyright (c) Microsoft. All rights reserved.
// Copyright (c) Microsoft. All rights reserved.
using System.Threading.Tasks;
using Microsoft.Agents.AI.Compaction;
@@ -16,7 +16,7 @@ public class TruncationCompactionStrategyTests
{
// Arrange
TruncationCompactionStrategy strategy = new(maxGroups: 5);
MessageGroups groups = MessageGroups.Create(
MessageIndex groups = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Hello"),
new ChatMessage(ChatRole.Assistant, "Hi!"),
@@ -35,7 +35,7 @@ public class TruncationCompactionStrategyTests
{
// Arrange
TruncationCompactionStrategy strategy = new(maxGroups: 2);
MessageGroups groups = MessageGroups.Create(
MessageIndex groups = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Hello"),
new ChatMessage(ChatRole.Assistant, "Hi!"),
@@ -58,7 +58,7 @@ public class TruncationCompactionStrategyTests
ChatMessage msg3 = new(ChatRole.User, "Second");
ChatMessage msg4 = new(ChatRole.Assistant, "Response 2");
MessageGroups groups = MessageGroups.Create([msg1, msg2, msg3, msg4]);
MessageIndex groups = MessageIndex.Create([msg1, msg2, msg3, msg4]);
// Act
bool result = await strategy.CompactAsync(groups);
@@ -82,7 +82,7 @@ public class TruncationCompactionStrategyTests
ChatMessage msg2 = new(ChatRole.Assistant, "Response 1");
ChatMessage msg3 = new(ChatRole.User, "Second");
MessageGroups groups = MessageGroups.Create([systemMsg, msg1, msg2, msg3]);
MessageIndex groups = MessageIndex.Create([systemMsg, msg1, msg2, msg3]);
// Act
bool result = await strategy.CompactAsync(groups);
@@ -109,7 +109,7 @@ public class TruncationCompactionStrategyTests
ChatMessage msg2 = new(ChatRole.Assistant, "Response");
ChatMessage msg3 = new(ChatRole.User, "Second");
MessageGroups groups = MessageGroups.Create([systemMsg, msg1, msg2, msg3]);
MessageIndex groups = MessageIndex.Create([systemMsg, msg1, msg2, msg3]);
// Act
bool result = await strategy.CompactAsync(groups);
@@ -133,7 +133,7 @@ public class TruncationCompactionStrategyTests
ChatMessage toolResult = new(ChatRole.Tool, "Sunny");
ChatMessage finalResponse = new(ChatRole.User, "Thanks!");
MessageGroups groups = MessageGroups.Create([assistantToolCall, toolResult, finalResponse]);
MessageIndex groups = MessageIndex.Create([assistantToolCall, toolResult, finalResponse]);
// Act
bool result = await strategy.CompactAsync(groups);
@@ -152,7 +152,7 @@ public class TruncationCompactionStrategyTests
{
// Arrange
TruncationCompactionStrategy strategy = new(maxGroups: 1);
MessageGroups groups = MessageGroups.Create(
MessageIndex groups = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Old"),
new ChatMessage(ChatRole.User, "New"),
@@ -171,7 +171,7 @@ public class TruncationCompactionStrategyTests
{
// Arrange
TruncationCompactionStrategy strategy = new(maxGroups: 1);
MessageGroups groups = MessageGroups.Create(
MessageIndex groups = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Already excluded"),
new ChatMessage(ChatRole.User, "Included 1"),