mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Stable
This commit is contained in:
@@ -39,23 +39,18 @@ static string LookupPrice([Description("The product name to look up.")] string p
|
||||
};
|
||||
|
||||
// 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),
|
||||
new ToolResultCompactionStrategy(CompactionTriggers.TokensExceed(0x200)),
|
||||
|
||||
// 2. Moderate: use an LLM to summarize older conversation spans into a concise message
|
||||
new SummarizationCompactionStrategy(summarizerChatClient, MaxGroups)
|
||||
new SummarizationCompactionStrategy(summarizerChatClient, CompactionTriggers.TokensExceed(0x500)),
|
||||
|
||||
// 3. Aggressive: keep only the last N user turns and their responses
|
||||
//new SlidingWindowCompactionStrategy(MaxTurns),
|
||||
new SlidingWindowCompactionStrategy(maximumTurns: 4),
|
||||
|
||||
// 4. Emergency: drop oldest groups until under the token budget
|
||||
//new TruncationCompactionStrategy(MaxGroups)
|
||||
);
|
||||
new TruncationCompactionStrategy(CompactionTriggers.TokensExceed(0x8000)));
|
||||
|
||||
// Create the agent with an in-memory chat history provider whose reducer is the compaction pipeline.
|
||||
AIAgent agent =
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Microsoft.Agents.AI.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// Defines a strategy for compacting 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>
|
||||
/// 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"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public interface ICompactionStrategy
|
||||
{
|
||||
/// <summary>
|
||||
/// Compacts the specified message groups in place.
|
||||
/// </summary>
|
||||
/// <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(MessageIndex groups, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
// 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="ICompactionStrategy"/> 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>
|
||||
/// When <see cref="EarlyStop"/> is <see langword="true"/> and a <see cref="TargetIncludedGroupCount"/> is configured,
|
||||
/// the pipeline stops executing after a strategy reduces the included group count to or below the target.
|
||||
/// This avoids unnecessary work when an earlier strategy is sufficient.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class PipelineCompactionStrategy : ICompactionStrategy
|
||||
{
|
||||
/// <summary>
|
||||
/// 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>
|
||||
///// <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)];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the ordered list of strategies in this pipeline.
|
||||
/// </summary>
|
||||
public IReadOnlyList<ICompactionStrategy> Strategies { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the pipeline should stop executing after a strategy
|
||||
/// brings the included group count to or below <see cref="TargetIncludedGroupCount"/>.
|
||||
/// </summary>
|
||||
/// <value>
|
||||
/// Defaults to <see langword="false"/>, meaning all strategies are always executed.
|
||||
/// </value>
|
||||
public bool EarlyStop { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the target number of included groups at which the pipeline stops
|
||||
/// when <see cref="EarlyStop"/> is <see langword="true"/>.
|
||||
/// </summary>
|
||||
/// <value>
|
||||
/// Defaults to <see langword="null"/>, meaning early stop checks are not performed
|
||||
/// even when <see cref="EarlyStop"/> is <see langword="true"/>.
|
||||
/// </value>
|
||||
public int? TargetIncludedGroupCount { get; set; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<bool> CompactAsync(MessageIndex groups, CancellationToken cancellationToken = default)
|
||||
{
|
||||
bool anyCompacted = false;
|
||||
|
||||
foreach (ICompactionStrategy strategy in this.Strategies)
|
||||
{
|
||||
bool compacted = await strategy.CompactAsync(groups, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (compacted)
|
||||
{
|
||||
anyCompacted = true;
|
||||
}
|
||||
|
||||
if (this.EarlyStop && this.TargetIncludedGroupCount is int targetIncludedGroupCount && groups.IncludedGroupCount <= targetIncludedGroupCount)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return anyCompacted;
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Agents.AI.Compaction;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
@@ -29,7 +29,6 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.AI.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.ML.Tokenizers" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -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;
|
||||
@@ -47,7 +47,7 @@ public sealed class ChatClientAgentOptions
|
||||
public IEnumerable<AIContextProvider>? AIContextProviders { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the <see cref="ICompactionStrategy"/> to use for in-run context compaction.
|
||||
/// Gets or sets the <see cref="CompactionStrategy"/> to use for in-run context compaction.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
@@ -57,10 +57,10 @@ public sealed class ChatClientAgentOptions
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The strategy organizes messages into atomic groups (preserving tool-call/result pairings)
|
||||
/// before applying compaction logic. See <see cref="ICompactionStrategy"/> for details.
|
||||
/// before applying compaction logic. See <see cref="CompactionStrategy"/> for details.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public ICompactionStrategy? CompactionStrategy { get; set; }
|
||||
public CompactionStrategy? CompactionStrategy { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether to use the provided <see cref="IChatClient"/> instance as is,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
@@ -12,7 +13,7 @@ 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
|
||||
/// 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>
|
||||
@@ -28,7 +29,7 @@ namespace Microsoft.Agents.AI.Compaction;
|
||||
/// </remarks>
|
||||
internal sealed class CompactingChatClient : DelegatingChatClient
|
||||
{
|
||||
private readonly ICompactionStrategy _compactionStrategy;
|
||||
private readonly CompactionStrategy _compactionStrategy;
|
||||
private readonly ProviderSessionState<State> _sessionState;
|
||||
|
||||
/// <summary>
|
||||
@@ -36,7 +37,7 @@ internal sealed class CompactingChatClient : DelegatingChatClient
|
||||
/// </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)
|
||||
public CompactingChatClient(IChatClient innerClient, CompactionStrategy compactionStrategy)
|
||||
: base(innerClient)
|
||||
{
|
||||
this._compactionStrategy = Throw.IfNull(compactionStrategy);
|
||||
@@ -75,7 +76,7 @@ internal sealed class CompactingChatClient : DelegatingChatClient
|
||||
Throw.IfNull(serviceType);
|
||||
|
||||
return
|
||||
serviceKey is null && serviceType.IsInstanceOfType(typeof(ICompactionStrategy)) ?
|
||||
serviceKey is null && serviceType.IsInstanceOfType(typeof(CompactionStrategy)) ?
|
||||
this._compactionStrategy :
|
||||
base.GetService(serviceType, serviceKey);
|
||||
}
|
||||
@@ -108,8 +109,12 @@ internal sealed class CompactingChatClient : DelegatingChatClient
|
||||
messageIndex = MessageIndex.Create(messageList);
|
||||
}
|
||||
|
||||
// Apply compaction
|
||||
// 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)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
// 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>
|
||||
/// 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>
|
||||
protected CompactionStrategy(CompactionTrigger trigger)
|
||||
{
|
||||
this.Trigger = Throw.IfNull(trigger);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the trigger predicate that controls when compaction proceeds.
|
||||
/// </summary>
|
||||
protected CompactionTrigger Trigger { 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.
|
||||
/// </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,100 @@
|
||||
// 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 triger compaction, regardless of the message index state.
|
||||
/// </summary>
|
||||
public static readonly CompactionTrigger Always =
|
||||
_ => true;
|
||||
|
||||
/// <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,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,97 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Microsoft.Agents.AI.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// A compaction strategy that keeps only the most recent user turns and their
|
||||
/// associated response groups, removing older turns to bound conversation length.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This strategy always preserves system messages. It identifies user turns in the
|
||||
/// conversation (via <see cref="MessageGroup.TurnIndex"/>) and keeps the last
|
||||
/// <see cref="MaxTurns"/> turns along with all response groups (assistant replies,
|
||||
/// tool call groups) that belong to each kept turn.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The <see cref="CompactionTrigger"/> predicate controls when compaction proceeds.
|
||||
/// When <see langword="null"/>, a default trigger of <see cref="CompactionTriggers.TurnsExceed"/>
|
||||
/// with <see cref="MaxTurns"/> is used.
|
||||
/// </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 maximum number of user turns to retain before compaction occurs. This default is a reasonable starting point
|
||||
/// for many conversations, but should be tuned based on the expected conversation length and token budget.
|
||||
/// </summary>
|
||||
public const int DefaultMaximumTurns = 32;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SlidingWindowCompactionStrategy"/> class.
|
||||
/// </summary>
|
||||
/// <param name="maximumTurns">
|
||||
/// The maximum number of user turns to keep. Older turns and their associated responses are removed.
|
||||
/// </param>
|
||||
public SlidingWindowCompactionStrategy(int maximumTurns = DefaultMaximumTurns)
|
||||
: base(CompactionTriggers.TurnsExceed(maximumTurns))
|
||||
{
|
||||
this.MaxTurns = maximumTurns;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the maximum number of user turns to retain after compaction.
|
||||
/// </summary>
|
||||
public int MaxTurns { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override Task<bool> ApplyCompactionAsync(MessageIndex index, CancellationToken cancellationToken)
|
||||
{
|
||||
// Collect distinct included turn indices in order
|
||||
List<int> includedTurns = [];
|
||||
foreach (MessageGroup group in index.Groups)
|
||||
{
|
||||
if (!group.IsExcluded && group.TurnIndex is int turnIndex && !includedTurns.Contains(turnIndex))
|
||||
{
|
||||
includedTurns.Add(turnIndex);
|
||||
}
|
||||
}
|
||||
|
||||
if (includedTurns.Count <= this.MaxTurns)
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
// Determine which turn indices to exclude (oldest)
|
||||
int turnsToRemove = includedTurns.Count - this.MaxTurns;
|
||||
HashSet<int> excludedTurnIndices = [.. includedTurns.Take(turnsToRemove)];
|
||||
|
||||
bool compacted = false;
|
||||
for (int i = 0; i < index.Groups.Count; i++)
|
||||
{
|
||||
MessageGroup group = index.Groups[i];
|
||||
if (group.IsExcluded || group.Kind == MessageGroupKind.System)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (group.TurnIndex is int ti && excludedTurnIndices.Contains(ti))
|
||||
{
|
||||
group.IsExcluded = true;
|
||||
group.ExcludeReason = $"Excluded by {nameof(SlidingWindowCompactionStrategy)}";
|
||||
compacted = true;
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult(compacted);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
@@ -9,36 +11,62 @@ using Microsoft.Shared.Diagnostics;
|
||||
namespace Microsoft.Agents.AI.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// A compaction strategy that summarizes older message groups using an <see cref="IChatClient"/>,
|
||||
/// replacing them with a single summary message.
|
||||
/// 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>
|
||||
/// When the number of included message groups exceeds <see cref="MaxGroupsBeforeSummary"/>,
|
||||
/// this strategy extracts the oldest non-system groups (up to the threshold), sends them
|
||||
/// to an <see cref="IChatClient"/> for summarization, and replaces those groups with a single
|
||||
/// assistant message containing the summary.
|
||||
/// This strategy protects system messages and the most recent <see cref="PreserveRecentGroups"/>
|
||||
/// 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>
|
||||
/// System message groups are always preserved and never included in summarization.
|
||||
/// 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 : ICompactionStrategy
|
||||
public sealed class SummarizationCompactionStrategy : CompactionStrategy
|
||||
{
|
||||
private const string DefaultSummarizationPrompt =
|
||||
"Summarize the following conversation concisely, preserving key facts, decisions, and context. " +
|
||||
"Focus on information that would be needed to continue the conversation effectively.";
|
||||
/// <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 chat client to use for generating summaries.</param>
|
||||
/// <param name="maxGroupsBeforeSummary">The maximum number of included groups allowed before summarization is triggered.</param>
|
||||
/// <param name="summarizationPrompt">Optional custom prompt for the summarization request. If <see langword="null"/>, a default prompt is used.</param>
|
||||
public SummarizationCompactionStrategy(IChatClient chatClient, int maxGroupsBeforeSummary, string? summarizationPrompt = null)
|
||||
/// <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="preserveRecentGroups">
|
||||
/// The number of most-recent non-system message groups to protect from summarization.
|
||||
/// Defaults to 4, preserving the current and recent exchanges.
|
||||
/// </param>
|
||||
/// <param name="summarizationPrompt">
|
||||
/// An optional custom system prompt for the summarization LLM call. When <see langword="null"/>,
|
||||
/// <see cref="DefaultSummarizationPrompt"/> is used.
|
||||
/// </param>
|
||||
public SummarizationCompactionStrategy(
|
||||
IChatClient chatClient,
|
||||
CompactionTrigger trigger,
|
||||
int preserveRecentGroups = 4,
|
||||
string? summarizationPrompt = null)
|
||||
: base(trigger)
|
||||
{
|
||||
this.ChatClient = Throw.IfNull(chatClient);
|
||||
this.MaxGroupsBeforeSummary = maxGroupsBeforeSummary;
|
||||
this.PreserveRecentGroups = preserveRecentGroups;
|
||||
this.SummarizationPrompt = summarizationPrompt ?? DefaultSummarizationPrompt;
|
||||
}
|
||||
|
||||
@@ -48,9 +76,9 @@ public sealed class SummarizationCompactionStrategy : ICompactionStrategy
|
||||
public IChatClient ChatClient { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the maximum number of included groups allowed before summarization is triggered.
|
||||
/// Gets the number of most-recent non-system groups to protect from summarization.
|
||||
/// </summary>
|
||||
public int MaxGroupsBeforeSummary { get; }
|
||||
public int PreserveRecentGroups { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the prompt used when requesting summaries from the chat client.
|
||||
@@ -58,25 +86,35 @@ public sealed class SummarizationCompactionStrategy : ICompactionStrategy
|
||||
public string SummarizationPrompt { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<bool> CompactAsync(MessageIndex groups, CancellationToken cancellationToken = default)
|
||||
protected override async Task<bool> ApplyCompactionAsync(MessageIndex index, CancellationToken cancellationToken)
|
||||
{
|
||||
int includedCount = groups.IncludedGroupCount;
|
||||
if (includedCount <= this.MaxGroupsBeforeSummary)
|
||||
// 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.PreserveRecentGroups, nonSystemIncludedCount);
|
||||
int groupsToSummarize = nonSystemIncludedCount - protectedFromEnd;
|
||||
|
||||
if (groupsToSummarize <= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Determine how many groups to summarize (keep the most recent MaxGroupsBeforeSummary groups)
|
||||
int groupsToSummarize = includedCount - this.MaxGroupsBeforeSummary;
|
||||
|
||||
// Collect the oldest non-system included groups for summarization
|
||||
StringBuilder conversationText = new();
|
||||
int summarized = 0;
|
||||
int insertIndex = -1;
|
||||
|
||||
for (int i = 0; i < groups.Groups.Count && summarized < groupsToSummarize; i++)
|
||||
for (int i = 0; i < index.Groups.Count && summarized < groupsToSummarize; i++)
|
||||
{
|
||||
MessageGroup group = groups.Groups[i];
|
||||
MessageGroup group = index.Groups[i];
|
||||
if (group.IsExcluded || group.Kind == MessageGroupKind.System)
|
||||
{
|
||||
continue;
|
||||
@@ -98,7 +136,7 @@ public sealed class SummarizationCompactionStrategy : ICompactionStrategy
|
||||
}
|
||||
|
||||
group.IsExcluded = true;
|
||||
group.ExcludeReason = "Summarized by SummarizationCompactionStrategy";
|
||||
group.ExcludeReason = $"Summarized by {nameof(SummarizationCompactionStrategy)}";
|
||||
summarized++;
|
||||
}
|
||||
|
||||
@@ -111,23 +149,27 @@ public sealed class SummarizationCompactionStrategy : ICompactionStrategy
|
||||
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 = response.Text ?? string.Empty;
|
||||
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 of earlier conversation]: {summaryText}");
|
||||
ChatMessage summaryMessage = new(ChatRole.Assistant, $"[Summary]\n{summaryText}");
|
||||
(summaryMessage.AdditionalProperties ??= [])[MessageGroup.SummaryPropertyKey] = true;
|
||||
|
||||
if (insertIndex >= 0)
|
||||
{
|
||||
groups.InsertGroup(insertIndex, MessageGroupKind.Summary, [summaryMessage]);
|
||||
index.InsertGroup(insertIndex, MessageGroupKind.Summary, [summaryMessage]);
|
||||
}
|
||||
else
|
||||
{
|
||||
groups.AddGroup(MessageGroupKind.Summary, [summaryMessage]);
|
||||
index.AddGroup(MessageGroupKind.Summary, [summaryMessage]);
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
// 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>
|
||||
/// 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 number of most-recent non-system groups to protect from collapsing.
|
||||
/// </summary>
|
||||
public const int DefaultPreserveRecentGroups = 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="preserveRecentGroups">
|
||||
/// The number of most-recent non-system message groups to protect from collapsing.
|
||||
/// Defaults to <see cref="DefaultPreserveRecentGroups"/>, ensuring the current turn's tool interactions remain visible.
|
||||
/// </param>
|
||||
public ToolResultCompactionStrategy(CompactionTrigger trigger, int preserveRecentGroups = DefaultPreserveRecentGroups)
|
||||
: base(trigger)
|
||||
{
|
||||
this.PreserveRecentGroups = preserveRecentGroups;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of most-recent non-system groups to protect from collapsing.
|
||||
/// </summary>
|
||||
public int PreserveRecentGroups { 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.PreserveRecentGroups);
|
||||
HashSet<int> protectedGroupIndices = [];
|
||||
for (int i = protectedStart; i < nonSystemIncludedIndices.Count; i++)
|
||||
{
|
||||
protectedGroupIndices.Add(nonSystemIncludedIndices[i]);
|
||||
}
|
||||
|
||||
// Process from end to start so insertions don't shift earlier indices
|
||||
bool compacted = false;
|
||||
for (int i = index.Groups.Count - 1; i >= 0; i--)
|
||||
{
|
||||
MessageGroup group = index.Groups[i];
|
||||
if (group.IsExcluded || group.Kind != MessageGroupKind.ToolCall || protectedGroupIndices.Contains(i))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// 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(i + 1, MessageGroupKind.AssistantText, [new ChatMessage(ChatRole.Assistant, summary)], group.TurnIndex);
|
||||
|
||||
compacted = true;
|
||||
}
|
||||
|
||||
return Task.FromResult(compacted);
|
||||
}
|
||||
}
|
||||
@@ -6,72 +6,82 @@ using System.Threading.Tasks;
|
||||
namespace Microsoft.Agents.AI.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// A compaction strategy that keeps the most recent message groups up to a specified limit,
|
||||
/// optionally preserving system message groups.
|
||||
/// A compaction strategy that removes the oldest non-system message groups,
|
||||
/// keeping the most recent groups up to <see cref="PreserveRecentGroups"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This strategy implements a sliding window approach: it marks older groups as excluded
|
||||
/// while keeping the most recent groups within the configured <see cref="MaxGroups"/> limit.
|
||||
/// System message groups can optionally be preserved regardless of their position.
|
||||
/// 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>
|
||||
/// This strategy respects atomic group preservation — tool call groups (assistant message + tool results)
|
||||
/// are always kept or excluded together.
|
||||
/// 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 : ICompactionStrategy
|
||||
public sealed class TruncationCompactionStrategy : CompactionStrategy
|
||||
{
|
||||
/// <summary>
|
||||
/// The default number of most-recent non-system groups to protect from collapsing.
|
||||
/// </summary>
|
||||
public const int DefaultPreserveRecentGroups = 32;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TruncationCompactionStrategy"/> class.
|
||||
/// </summary>
|
||||
/// <param name="maxGroups">The maximum number of message groups to keep. Must be greater than zero.</param>
|
||||
/// <param name="preserveSystemMessages">Whether to preserve system message groups regardless of position. Defaults to <see langword="true"/>.</param>
|
||||
public TruncationCompactionStrategy(int maxGroups, bool preserveSystemMessages = true)
|
||||
/// <param name="trigger">
|
||||
/// The <see cref="CompactionTrigger"/> that controls when compaction proceeds.
|
||||
/// </param>
|
||||
/// <param name="preserveRecentGroups">
|
||||
/// The minimum number of most-recent non-system message groups to keep.
|
||||
/// Defaults to 1 so that at least the latest exchange is always preserved.
|
||||
/// </param>
|
||||
public TruncationCompactionStrategy(CompactionTrigger trigger, int preserveRecentGroups = DefaultPreserveRecentGroups)
|
||||
: base(trigger)
|
||||
{
|
||||
this.MaxGroups = maxGroups;
|
||||
this.PreserveSystemMessages = preserveSystemMessages;
|
||||
this.PreserveRecentGroups = preserveRecentGroups;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the maximum number of message groups to retain after compaction.
|
||||
/// Gets the minimum number of most-recent non-system message groups to retain after compaction.
|
||||
/// </summary>
|
||||
public int MaxGroups { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether system message groups are preserved regardless of their position in the conversation.
|
||||
/// </summary>
|
||||
public bool PreserveSystemMessages { get; }
|
||||
public int PreserveRecentGroups { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<bool> CompactAsync(MessageIndex groups, CancellationToken cancellationToken = default)
|
||||
protected override Task<bool> ApplyCompactionAsync(MessageIndex index, CancellationToken cancellationToken)
|
||||
{
|
||||
int includedCount = groups.IncludedGroupCount;
|
||||
if (includedCount <= this.MaxGroups)
|
||||
// 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.PreserveRecentGroups;
|
||||
if (maxRemovable <= 0)
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
int excessCount = includedCount - this.MaxGroups;
|
||||
bool compacted = false;
|
||||
|
||||
// Exclude oldest non-system groups first (iterate from the beginning)
|
||||
for (int i = 0; i < groups.Groups.Count && excessCount > 0; i++)
|
||||
bool compacted = false;
|
||||
int removed = 0;
|
||||
for (int i = 0; i < index.Groups.Count && removed < maxRemovable; i++)
|
||||
{
|
||||
MessageGroup group = groups.Groups[i];
|
||||
if (group.IsExcluded)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (this.PreserveSystemMessages && group.Kind == MessageGroupKind.System)
|
||||
MessageGroup group = index.Groups[i];
|
||||
if (group.IsExcluded || group.Kind == MessageGroupKind.System)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
group.IsExcluded = true;
|
||||
group.ExcludeReason = "Truncated by TruncationCompactionStrategy";
|
||||
excessCount--;
|
||||
group.ExcludeReason = $"Truncated by {nameof(TruncationCompactionStrategy)}";
|
||||
removed++;
|
||||
compacted = true;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
-282
@@ -1,282 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Compaction;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// Contains tests for the <see cref="PipelineCompactionStrategy"/> class.
|
||||
/// </summary>
|
||||
public class PipelineCompactionStrategyTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task CompactAsync_ExecutesAllStrategiesInOrder()
|
||||
{
|
||||
// Arrange
|
||||
List<string> executionOrder = [];
|
||||
Mock<ICompactionStrategy> strategy1 = new();
|
||||
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<MessageIndex>(), It.IsAny<CancellationToken>()))
|
||||
.Callback(() => executionOrder.Add("second"))
|
||||
.ReturnsAsync(false);
|
||||
|
||||
PipelineCompactionStrategy pipeline = new([strategy1.Object, strategy2.Object]);
|
||||
MessageIndex groups = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]);
|
||||
|
||||
// Act
|
||||
await pipeline.CompactAsync(groups);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(["first", "second"], executionOrder);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsync_ReturnsFalse_WhenNoStrategyCompacts()
|
||||
{
|
||||
// Arrange
|
||||
Mock<ICompactionStrategy> strategy1 = new();
|
||||
strategy1.Setup(s => s.CompactAsync(It.IsAny<MessageIndex>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(false);
|
||||
|
||||
PipelineCompactionStrategy pipeline = new([strategy1.Object]);
|
||||
MessageIndex groups = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]);
|
||||
|
||||
// Act
|
||||
bool result = await pipeline.CompactAsync(groups);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsync_ReturnsTrue_WhenAnyStrategyCompacts()
|
||||
{
|
||||
// Arrange
|
||||
Mock<ICompactionStrategy> strategy1 = new();
|
||||
strategy1.Setup(s => s.CompactAsync(It.IsAny<MessageIndex>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(false);
|
||||
|
||||
Mock<ICompactionStrategy> strategy2 = new();
|
||||
strategy2.Setup(s => s.CompactAsync(It.IsAny<MessageIndex>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(true);
|
||||
|
||||
PipelineCompactionStrategy pipeline = new([strategy1.Object, strategy2.Object]);
|
||||
MessageIndex groups = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]);
|
||||
|
||||
// Act
|
||||
bool result = await pipeline.CompactAsync(groups);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsync_ContinuesAfterFirstCompaction_WhenEarlyStopDisabled()
|
||||
{
|
||||
// Arrange
|
||||
Mock<ICompactionStrategy> strategy1 = new();
|
||||
strategy1.Setup(s => s.CompactAsync(It.IsAny<MessageIndex>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(true);
|
||||
|
||||
Mock<ICompactionStrategy> strategy2 = new();
|
||||
strategy2.Setup(s => s.CompactAsync(It.IsAny<MessageIndex>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(false);
|
||||
|
||||
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<MessageIndex>(), It.IsAny<CancellationToken>()), Times.Once);
|
||||
strategy2.Verify(s => s.CompactAsync(It.IsAny<MessageIndex>(), It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsync_StopsEarly_WhenTargetReached()
|
||||
{
|
||||
// Arrange — first strategy reduces to target
|
||||
Mock<ICompactionStrategy> strategy1 = new();
|
||||
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;
|
||||
})
|
||||
.ReturnsAsync(true);
|
||||
|
||||
Mock<ICompactionStrategy> strategy2 = new();
|
||||
strategy2.Setup(s => s.CompactAsync(It.IsAny<MessageIndex>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(false);
|
||||
|
||||
PipelineCompactionStrategy pipeline = new([strategy1.Object, strategy2.Object])
|
||||
{
|
||||
EarlyStop = true,
|
||||
TargetIncludedGroupCount = 2,
|
||||
};
|
||||
|
||||
MessageIndex groups = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "First"),
|
||||
new ChatMessage(ChatRole.Assistant, "Response"),
|
||||
new ChatMessage(ChatRole.User, "Second"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
bool result = await pipeline.CompactAsync(groups);
|
||||
|
||||
// Assert — strategy2 should not have been called
|
||||
Assert.True(result);
|
||||
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]
|
||||
public async Task CompactAsync_DoesNotStopEarly_WhenTargetNotReached()
|
||||
{
|
||||
// Arrange — first strategy does NOT bring count to target
|
||||
Mock<ICompactionStrategy> strategy1 = new();
|
||||
strategy1.Setup(s => s.CompactAsync(It.IsAny<MessageIndex>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(false);
|
||||
|
||||
Mock<ICompactionStrategy> strategy2 = new();
|
||||
strategy2.Setup(s => s.CompactAsync(It.IsAny<MessageIndex>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(false);
|
||||
|
||||
PipelineCompactionStrategy pipeline = new([strategy1.Object, strategy2.Object])
|
||||
{
|
||||
EarlyStop = true,
|
||||
TargetIncludedGroupCount = 1,
|
||||
};
|
||||
|
||||
MessageIndex groups = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "First"),
|
||||
new ChatMessage(ChatRole.User, "Second"),
|
||||
new ChatMessage(ChatRole.User, "Third"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
await pipeline.CompactAsync(groups);
|
||||
|
||||
// Assert — both strategies were called since target was never reached
|
||||
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]
|
||||
public async Task CompactAsync_EarlyStopIgnored_WhenNoTargetSet()
|
||||
{
|
||||
// Arrange
|
||||
Mock<ICompactionStrategy> strategy1 = new();
|
||||
strategy1.Setup(s => s.CompactAsync(It.IsAny<MessageIndex>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(true);
|
||||
|
||||
Mock<ICompactionStrategy> strategy2 = new();
|
||||
strategy2.Setup(s => s.CompactAsync(It.IsAny<MessageIndex>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(false);
|
||||
|
||||
PipelineCompactionStrategy pipeline = new([strategy1.Object, strategy2.Object])
|
||||
{
|
||||
EarlyStop = true,
|
||||
// TargetIncludedGroupCount is null
|
||||
};
|
||||
|
||||
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<MessageIndex>(), It.IsAny<CancellationToken>()), Times.Once);
|
||||
strategy2.Verify(s => s.CompactAsync(It.IsAny<MessageIndex>(), It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsync_ComposesStrategies_EndToEnd()
|
||||
{
|
||||
// Arrange — pipeline: first exclude oldest 2 non-system groups, then exclude 2 more
|
||||
Mock<ICompactionStrategy> phase1 = new();
|
||||
phase1.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);
|
||||
|
||||
Mock<ICompactionStrategy> phase2 = new();
|
||||
phase2.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);
|
||||
|
||||
PipelineCompactionStrategy pipeline = new([phase1.Object, phase2.Object]);
|
||||
|
||||
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);
|
||||
|
||||
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]
|
||||
public async Task CompactAsync_EmptyPipeline_ReturnsFalseAsync()
|
||||
{
|
||||
// Arrange
|
||||
PipelineCompactionStrategy pipeline = new(new List<ICompactionStrategy>());
|
||||
MessageIndex groups = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]);
|
||||
|
||||
// Act
|
||||
bool result = await pipeline.CompactAsync(groups);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
// 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 TokensExceed_ReturnsTrueWhenAboveThreshold()
|
||||
{
|
||||
// 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 TokensExceed_ReturnsFalseWhenBelowThreshold()
|
||||
{
|
||||
CompactionTrigger trigger = CompactionTriggers.TokensExceed(999_999);
|
||||
MessageIndex index = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hi")]);
|
||||
|
||||
Assert.False(trigger(index));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MessagesExceed_ReturnsExpectedResult()
|
||||
{
|
||||
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 TurnsExceed_ReturnsExpectedResult()
|
||||
{
|
||||
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 GroupsExceed_ReturnsExpectedResult()
|
||||
{
|
||||
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 HasToolCalls_ReturnsTrueWhenToolCallGroupExists()
|
||||
{
|
||||
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 HasToolCalls_ReturnsFalseWhenNoToolCallGroup()
|
||||
{
|
||||
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 All_RequiresAllConditions()
|
||||
{
|
||||
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 Any_RequiresAtLeastOneCondition()
|
||||
{
|
||||
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 All_EmptyTriggers_ReturnsTrue()
|
||||
{
|
||||
CompactionTrigger trigger = CompactionTriggers.All();
|
||||
MessageIndex index = MessageIndex.Create([new ChatMessage(ChatRole.User, "A")]);
|
||||
Assert.True(trigger(index));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Any_EmptyTriggers_ReturnsFalse()
|
||||
{
|
||||
CompactionTrigger trigger = CompactionTriggers.Any();
|
||||
MessageIndex index = MessageIndex.Create([new ChatMessage(ChatRole.User, "A")]);
|
||||
Assert.False(trigger(index));
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -8,7 +8,7 @@
|
||||
//using Microsoft.Extensions.AI;
|
||||
//using Moq;
|
||||
|
||||
//namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction;
|
||||
//namespace Microsoft.Agents.AI.UnitTests.Compaction;
|
||||
|
||||
///// <summary>
|
||||
///// Contains tests for the compaction integration with <see cref="InMemoryChatHistoryProvider"/>.
|
||||
+2
-2
@@ -1,10 +1,10 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.Agents.AI.Compaction;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction;
|
||||
namespace Microsoft.Agents.AI.UnitTests.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// Contains tests for the <see cref="MessageIndex"/> class.
|
||||
+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 CompactAsync_ExecutesAllStrategiesInOrderAsync()
|
||||
{
|
||||
// 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 CompactAsync_ReturnsFalse_WhenNoStrategyCompactsAsync()
|
||||
{
|
||||
// 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 CompactAsync_ReturnsTrue_WhenAnyStrategyCompactsAsync()
|
||||
{
|
||||
// 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 CompactAsync_ContinuesAfterFirstCompaction_WhenEarlyStopDisabledAsync()
|
||||
{
|
||||
// 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 CompactAsync_ComposesStrategies_EndToEndAsync()
|
||||
{
|
||||
// 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 CompactAsync_EmptyPipeline_ReturnsFalseAsync()
|
||||
{
|
||||
// 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
// 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 CompactAsync_BelowMaxTurns_ReturnsFalseAsync()
|
||||
{
|
||||
// Arrange
|
||||
SlidingWindowCompactionStrategy strategy = new(maximumTurns: 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 CompactAsync_ExceedsMaxTurns_ExcludesOldestTurnsAsync()
|
||||
{
|
||||
// Arrange — keep 2 turns, conversation has 3
|
||||
SlidingWindowCompactionStrategy strategy = new(maximumTurns: 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 CompactAsync_PreservesSystemMessagesAsync()
|
||||
{
|
||||
// Arrange
|
||||
SlidingWindowCompactionStrategy strategy = new(maximumTurns: 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 CompactAsync_PreservesToolCallGroupsInKeptTurnsAsync()
|
||||
{
|
||||
// Arrange
|
||||
SlidingWindowCompactionStrategy strategy = new(maximumTurns: 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 CompactAsync_CustomTrigger_OverridesDefaultAsync()
|
||||
{
|
||||
// Arrange — custom trigger: only compact when tokens exceed threshold
|
||||
SlidingWindowCompactionStrategy strategy = new(maximumTurns: 99);
|
||||
|
||||
MessageIndex groups = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
new ChatMessage(ChatRole.User, "Q3"),
|
||||
]);
|
||||
|
||||
// Act — tokens are tiny, trigger not met
|
||||
bool result = await strategy.CompactAsync(groups);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsync_IncludedMessages_ContainOnlyKeptTurnsAsync()
|
||||
{
|
||||
// Arrange
|
||||
SlidingWindowCompactionStrategy strategy = new(maximumTurns: 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);
|
||||
}
|
||||
}
|
||||
+195
@@ -0,0 +1,195 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
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="ToolResultCompactionStrategy"/> class.
|
||||
/// </summary>
|
||||
public class ToolResultCompactionStrategyTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task CompactAsync_TriggerNotMet_ReturnsFalseAsync()
|
||||
{
|
||||
// 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 CompactAsync_CollapsesOldToolGroupsAsync()
|
||||
{
|
||||
// Arrange — always trigger
|
||||
ToolResultCompactionStrategy strategy = new(
|
||||
trigger: _ => true,
|
||||
preserveRecentGroups: 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 CompactAsync_PreservesRecentToolGroupsAsync()
|
||||
{
|
||||
// Arrange — protect 2 recent non-system groups (the tool group + Q2)
|
||||
ToolResultCompactionStrategy strategy = new(
|
||||
trigger: _ => true,
|
||||
preserveRecentGroups: 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 CompactAsync_PreservesSystemMessagesAsync()
|
||||
{
|
||||
// Arrange
|
||||
ToolResultCompactionStrategy strategy = new(
|
||||
trigger: _ => true,
|
||||
preserveRecentGroups: 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 CompactAsync_ExtractsMultipleToolNamesAsync()
|
||||
{
|
||||
// Arrange — assistant calls two tools
|
||||
ToolResultCompactionStrategy strategy = new(
|
||||
trigger: _ => true,
|
||||
preserveRecentGroups: 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 CompactAsync_NoToolGroups_ReturnsFalseAsync()
|
||||
{
|
||||
// Arrange — trigger fires but no tool groups to collapse
|
||||
ToolResultCompactionStrategy strategy = new(
|
||||
trigger: _ => true,
|
||||
preserveRecentGroups: 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 CompactAsync_CompoundTrigger_RequiresTokensAndToolCallsAsync()
|
||||
{
|
||||
// Arrange — compound: tokens > 0 AND has tool calls
|
||||
ToolResultCompactionStrategy strategy = new(
|
||||
CompactionTriggers.All(
|
||||
CompactionTriggers.TokensExceed(0),
|
||||
CompactionTriggers.HasToolCalls()),
|
||||
preserveRecentGroups: 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);
|
||||
}
|
||||
}
|
||||
+114
-82
@@ -1,5 +1,6 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Compaction;
|
||||
using Microsoft.Extensions.AI;
|
||||
@@ -11,78 +12,91 @@ namespace Microsoft.Agents.AI.UnitTests.Compaction;
|
||||
/// </summary>
|
||||
public class TruncationCompactionStrategyTests
|
||||
{
|
||||
private static readonly CompactionTrigger s_alwaysTrigger = _ => true;
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsync_BelowLimit_ReturnsFalseAsync()
|
||||
public async Task CompactAsync_AlwaysTrigger_CompactsToPreserveRecentAsync()
|
||||
{
|
||||
// Arrange
|
||||
TruncationCompactionStrategy strategy = new(maxGroups: 5);
|
||||
// Arrange — always-trigger means always compact
|
||||
TruncationCompactionStrategy strategy = new(s_alwaysTrigger, preserveRecentGroups: 1);
|
||||
MessageIndex groups = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Hello"),
|
||||
new ChatMessage(ChatRole.Assistant, "Hi!"),
|
||||
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.False(result);
|
||||
Assert.Equal(2, groups.IncludedGroupCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsync_AtLimit_ReturnsFalseAsync()
|
||||
{
|
||||
// Arrange
|
||||
TruncationCompactionStrategy strategy = new(maxGroups: 2);
|
||||
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 CompactAsync_ExceedsLimit_ExcludesOldestGroupsAsync()
|
||||
{
|
||||
// Arrange
|
||||
TruncationCompactionStrategy strategy = new(maxGroups: 2);
|
||||
ChatMessage msg1 = new(ChatRole.User, "First");
|
||||
ChatMessage msg2 = new(ChatRole.Assistant, "Response 1");
|
||||
ChatMessage msg3 = new(ChatRole.User, "Second");
|
||||
ChatMessage msg4 = new(ChatRole.Assistant, "Response 2");
|
||||
|
||||
MessageIndex groups = MessageIndex.Create([msg1, msg2, msg3, msg4]);
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(groups);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
Assert.Equal(1, groups.Groups.Count(g => !g.IsExcluded));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsync_TriggerNotMet_ReturnsFalseAsync()
|
||||
{
|
||||
// Arrange — trigger requires > 1000 tokens, conversation is tiny
|
||||
TruncationCompactionStrategy strategy = new(
|
||||
preserveRecentGroups: 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 CompactAsync_TriggerMet_ExcludesOldestGroupsAsync()
|
||||
{
|
||||
// Arrange — trigger on groups > 2
|
||||
TruncationCompactionStrategy strategy = new(
|
||||
preserveRecentGroups: 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
|
||||
Assert.True(result);
|
||||
Assert.Equal(1, groups.IncludedGroupCount);
|
||||
// Oldest 3 excluded, newest 1 kept
|
||||
Assert.True(groups.Groups[0].IsExcluded);
|
||||
Assert.True(groups.Groups[1].IsExcluded);
|
||||
Assert.False(groups.Groups[2].IsExcluded);
|
||||
Assert.True(groups.Groups[2].IsExcluded);
|
||||
Assert.False(groups.Groups[3].IsExcluded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsync_PreservesSystemMessages_WhenEnabledAsync()
|
||||
public async Task CompactAsync_PreservesSystemMessagesAsync()
|
||||
{
|
||||
// Arrange
|
||||
TruncationCompactionStrategy strategy = new(maxGroups: 2, preserveSystemMessages: true);
|
||||
ChatMessage systemMsg = new(ChatRole.System, "You are helpful.");
|
||||
ChatMessage msg1 = new(ChatRole.User, "First");
|
||||
ChatMessage msg2 = new(ChatRole.Assistant, "Response 1");
|
||||
ChatMessage msg3 = new(ChatRole.User, "Second");
|
||||
|
||||
MessageIndex groups = MessageIndex.Create([systemMsg, msg1, msg2, msg3]);
|
||||
TruncationCompactionStrategy strategy = new(s_alwaysTrigger, preserveRecentGroups: 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);
|
||||
@@ -92,34 +106,10 @@ public class TruncationCompactionStrategyTests
|
||||
// System message should be preserved
|
||||
Assert.False(groups.Groups[0].IsExcluded);
|
||||
Assert.Equal(MessageGroupKind.System, groups.Groups[0].Kind);
|
||||
// Oldest non-system groups should be excluded
|
||||
// Oldest non-system groups excluded
|
||||
Assert.True(groups.Groups[1].IsExcluded);
|
||||
Assert.True(groups.Groups[2].IsExcluded);
|
||||
// Most recent should remain
|
||||
Assert.False(groups.Groups[3].IsExcluded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsync_DoesNotPreserveSystemMessages_WhenDisabledAsync()
|
||||
{
|
||||
// Arrange
|
||||
TruncationCompactionStrategy strategy = new(maxGroups: 2, preserveSystemMessages: false);
|
||||
ChatMessage systemMsg = new(ChatRole.System, "You are helpful.");
|
||||
ChatMessage msg1 = new(ChatRole.User, "First");
|
||||
ChatMessage msg2 = new(ChatRole.Assistant, "Response");
|
||||
ChatMessage msg3 = new(ChatRole.User, "Second");
|
||||
|
||||
MessageIndex groups = MessageIndex.Create([systemMsg, msg1, msg2, msg3]);
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(groups);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
// System message should be excluded (oldest)
|
||||
Assert.True(groups.Groups[0].IsExcluded);
|
||||
Assert.True(groups.Groups[1].IsExcluded);
|
||||
Assert.False(groups.Groups[2].IsExcluded);
|
||||
// Most recent kept
|
||||
Assert.False(groups.Groups[3].IsExcluded);
|
||||
}
|
||||
|
||||
@@ -127,9 +117,9 @@ public class TruncationCompactionStrategyTests
|
||||
public async Task CompactAsync_PreservesToolCallGroupAtomicityAsync()
|
||||
{
|
||||
// Arrange
|
||||
TruncationCompactionStrategy strategy = new(maxGroups: 1);
|
||||
TruncationCompactionStrategy strategy = new(s_alwaysTrigger, preserveRecentGroups: 1);
|
||||
|
||||
ChatMessage assistantToolCall = new(ChatRole.Assistant, [new FunctionCallContent("call1", "get_weather")]);
|
||||
ChatMessage assistantToolCall= new(ChatRole.Assistant, [new FunctionCallContent("call1", "get_weather")]);
|
||||
ChatMessage toolResult = new(ChatRole.Tool, "Sunny");
|
||||
ChatMessage finalResponse = new(ChatRole.User, "Thanks!");
|
||||
|
||||
@@ -151,7 +141,7 @@ public class TruncationCompactionStrategyTests
|
||||
public async Task CompactAsync_SetsExcludeReasonAsync()
|
||||
{
|
||||
// Arrange
|
||||
TruncationCompactionStrategy strategy = new(maxGroups: 1);
|
||||
TruncationCompactionStrategy strategy = new(s_alwaysTrigger, preserveRecentGroups: 1);
|
||||
MessageIndex groups = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Old"),
|
||||
@@ -170,7 +160,7 @@ public class TruncationCompactionStrategyTests
|
||||
public async Task CompactAsync_SkipsAlreadyExcludedGroupsAsync()
|
||||
{
|
||||
// Arrange
|
||||
TruncationCompactionStrategy strategy = new(maxGroups: 1);
|
||||
TruncationCompactionStrategy strategy = new(s_alwaysTrigger, preserveRecentGroups: 1);
|
||||
MessageIndex groups = MessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Already excluded"),
|
||||
@@ -188,4 +178,46 @@ public class TruncationCompactionStrategyTests
|
||||
Assert.True(groups.Groups[1].IsExcluded); // newly excluded
|
||||
Assert.False(groups.Groups[2].IsExcluded); // kept
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsync_PreserveRecentGroups_KeepsMultipleAsync()
|
||||
{
|
||||
// Arrange — keep 2 most recent
|
||||
TruncationCompactionStrategy strategy = new(s_alwaysTrigger, preserveRecentGroups: 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 CompactAsync_NothingToRemove_ReturnsFalseAsync()
|
||||
{
|
||||
// Arrange — preserve 5 but only 2 groups
|
||||
TruncationCompactionStrategy strategy = new(s_alwaysTrigger, preserveRecentGroups: 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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user