Strategies

This commit is contained in:
Chris Rickman
2026-03-05 02:25:00 -08:00
Unverified
parent eb8406214e
commit 0e8b9b283f
7 changed files with 122 additions and 38 deletions
@@ -23,6 +23,12 @@ namespace Microsoft.Agents.AI.Compaction;
/// the trigger returns <see langword="false"/>.
/// </para>
/// <para>
/// An optional <b>target</b> condition controls when compaction stops. Strategies incrementally exclude
/// groups and re-evaluate the target after each exclusion, stopping as soon as the target returns
/// <see langword="true"/>. When no target is specified, it defaults to the inverse of the trigger —
/// meaning compaction stops when the trigger condition would no longer fire.
/// </para>
/// <para>
/// Strategies can be applied at three lifecycle points:
/// <list type="bullet">
/// <item><description><b>In-run</b>: During the tool loop, before each LLM call, to keep context within token limits.</description></item>
@@ -43,9 +49,16 @@ public abstract class CompactionStrategy
/// <param name="trigger">
/// The <see cref="CompactionTrigger"/> that determines whether compaction should proceed.
/// </param>
protected CompactionStrategy(CompactionTrigger trigger)
/// <param name="target">
/// An optional target condition that controls when compaction stops. Strategies re-evaluate
/// this predicate after each incremental exclusion and stop when it returns <see langword="true"/>.
/// When <see langword="null"/>, defaults to the inverse of the <paramref name="trigger"/> — compaction
/// stops as soon as the trigger condition would no longer fire.
/// </param>
protected CompactionStrategy(CompactionTrigger trigger, CompactionTrigger? target = null)
{
this.Trigger = Throw.IfNull(trigger);
this.Target = target ?? (index => !trigger(index));
}
/// <summary>
@@ -53,6 +66,12 @@ public abstract class CompactionStrategy
/// </summary>
protected CompactionTrigger Trigger { get; }
/// <summary>
/// Gets the target predicate that controls when compaction stops.
/// Strategies re-evaluate this after each incremental exclusion and stop when it returns <see langword="true"/>.
/// </summary>
protected CompactionTrigger Target { get; }
/// <summary>
/// Evaluates the <see cref="Trigger"/> and, when it fires, delegates to
/// <see cref="ApplyCompactionAsync"/> and reports compaction metrics.
@@ -98,7 +117,8 @@ public abstract class CompactionStrategy
/// <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.
/// report metrics — the base class handles both. Implementations should use <see cref="Target"/>
/// to determine when to stop compacting incrementally.
/// </remarks>
/// <param name="index">The message index to compact. The strategy mutates this collection in place.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
@@ -20,6 +20,14 @@ public static class CompactionTriggers
public static readonly CompactionTrigger Always =
_ => true;
/// <summary>
/// Creates a trigger that fires when the included token count is below the specified maximum.
/// </summary>
/// <param name="maxTokens">The token threshold. Compaction proceeds when included tokens exceed this value.</param>
/// <returns>A <see cref="CompactionTrigger"/> that evaluates included token count.</returns>
public static CompactionTrigger TokensBelow(int maxTokens) =>
index => index.IncludedTokenCount < maxTokens;
/// <summary>
/// Creates a trigger that fires when the included token count exceeds the specified maximum.
/// </summary>
@@ -1,7 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
@@ -42,8 +41,12 @@ public sealed class SlidingWindowCompactionStrategy : CompactionStrategy
/// <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))
/// <param name="target">
/// An optional target condition that controls when compaction stops. When <see langword="null"/>,
/// defaults to the inverse of the auto-derived trigger — compaction stops as soon as the turn count is within bounds.
/// </param>
public SlidingWindowCompactionStrategy(int maximumTurns = DefaultMaximumTurns, CompactionTrigger? target = null)
: base(CompactionTriggers.TurnsExceed(maximumTurns), target)
{
this.MaxTurns = maximumTurns;
}
@@ -71,24 +74,30 @@ public sealed class SlidingWindowCompactionStrategy : CompactionStrategy
return Task.FromResult(false);
}
// Determine which turn indices to exclude (oldest)
// Exclude one turn at a time from oldest, re-checking target after each
int turnsToRemove = includedTurns.Count - this.MaxTurns;
HashSet<int> excludedTurnIndices = [.. includedTurns.Take(turnsToRemove)];
bool compacted = false;
for (int i = 0; i < index.Groups.Count; i++)
for (int t = 0; t < turnsToRemove; t++)
{
MessageGroup group = index.Groups[i];
if (group.IsExcluded || group.Kind == MessageGroupKind.System)
int turnToExclude = includedTurns[t];
for (int i = 0; i < index.Groups.Count; i++)
{
continue;
MessageGroup group = index.Groups[i];
if (!group.IsExcluded && group.Kind != MessageGroupKind.System && group.TurnIndex == turnToExclude)
{
group.IsExcluded = true;
group.ExcludeReason = $"Excluded by {nameof(SlidingWindowCompactionStrategy)}";
}
}
if (group.TurnIndex is int ti && excludedTurnIndices.Contains(ti))
compacted = true;
// Stop when target condition is met
if (this.Target(index))
{
group.IsExcluded = true;
group.ExcludeReason = $"Excluded by {nameof(SlidingWindowCompactionStrategy)}";
compacted = true;
break;
}
}
@@ -58,12 +58,17 @@ public sealed class SummarizationCompactionStrategy : CompactionStrategy
/// An optional custom system prompt for the summarization LLM call. When <see langword="null"/>,
/// <see cref="DefaultSummarizationPrompt"/> is used.
/// </param>
/// <param name="target">
/// An optional target condition that controls when compaction stops. When <see langword="null"/>,
/// defaults to the inverse of the <paramref name="trigger"/> — compaction stops as soon as the trigger would no longer fire.
/// </param>
public SummarizationCompactionStrategy(
IChatClient chatClient,
CompactionTrigger trigger,
int preserveRecentGroups = 4,
string? summarizationPrompt = null)
: base(trigger)
string? summarizationPrompt = null,
CompactionTrigger? target = null)
: base(trigger, target)
{
this.ChatClient = Throw.IfNull(chatClient);
this.PreserveRecentGroups = preserveRecentGroups;
@@ -100,19 +105,19 @@ public sealed class SummarizationCompactionStrategy : CompactionStrategy
}
int protectedFromEnd = Math.Min(this.PreserveRecentGroups, nonSystemIncludedCount);
int groupsToSummarize = nonSystemIncludedCount - protectedFromEnd;
int maxSummarizable = nonSystemIncludedCount - protectedFromEnd;
if (groupsToSummarize <= 0)
if (maxSummarizable <= 0)
{
return false;
}
// Collect the oldest non-system included groups for summarization
// Mark oldest non-system groups for summarization one at a time until the target is met
StringBuilder conversationText = new();
int summarized = 0;
int insertIndex = -1;
for (int i = 0; i < index.Groups.Count && summarized < groupsToSummarize; i++)
for (int i = 0; i < index.Groups.Count && summarized < maxSummarizable; i++)
{
MessageGroup group = index.Groups[i];
if (group.IsExcluded || group.Kind == MessageGroupKind.System)
@@ -138,6 +143,12 @@ public sealed class SummarizationCompactionStrategy : CompactionStrategy
group.IsExcluded = true;
group.ExcludeReason = $"Summarized by {nameof(SummarizationCompactionStrategy)}";
summarized++;
// Stop marking when target condition is met
if (this.Target(index))
{
break;
}
}
if (summarized == 0)
@@ -145,7 +156,7 @@ public sealed class SummarizationCompactionStrategy : CompactionStrategy
return false;
}
// Generate summary using the chat client
// Generate summary using the chat client (single LLM call for all marked groups)
ChatResponse response = await this.ChatClient.GetResponseAsync(
[
new ChatMessage(ChatRole.System, this.SummarizationPrompt),
@@ -44,8 +44,12 @@ public sealed class ToolResultCompactionStrategy : CompactionStrategy
/// 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)
/// <param name="target">
/// An optional target condition that controls when compaction stops. When <see langword="null"/>,
/// defaults to the inverse of the <paramref name="trigger"/> — compaction stops as soon as the trigger would no longer fire.
/// </param>
public ToolResultCompactionStrategy(CompactionTrigger trigger, int preserveRecentGroups = DefaultPreserveRecentGroups, CompactionTrigger? target = null)
: base(trigger, target)
{
this.PreserveRecentGroups = preserveRecentGroups;
}
@@ -76,15 +80,30 @@ public sealed class ToolResultCompactionStrategy : CompactionStrategy
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--)
// Collect eligible tool groups in order (oldest first)
List<int> eligibleIndices = [];
for (int i = 0; i < index.Groups.Count; i++)
{
MessageGroup group = index.Groups[i];
if (group.IsExcluded || group.Kind != MessageGroupKind.ToolCall || protectedGroupIndices.Contains(i))
if (!group.IsExcluded && group.Kind == MessageGroupKind.ToolCall && !protectedGroupIndices.Contains(i))
{
continue;
eligibleIndices.Add(i);
}
}
if (eligibleIndices.Count == 0)
{
return Task.FromResult(false);
}
// Collapse one tool group at a time from oldest, re-checking target after each
bool compacted = false;
int offset = 0;
for (int e = 0; e < eligibleIndices.Count; e++)
{
int idx = eligibleIndices[e] + offset;
MessageGroup group = index.Groups[idx];
// Extract tool names from FunctionCallContent
List<string> toolNames = [];
@@ -107,9 +126,16 @@ public sealed class ToolResultCompactionStrategy : CompactionStrategy
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);
index.InsertGroup(idx + 1, MessageGroupKind.AssistantText, [new ChatMessage(ChatRole.Assistant, summary)], group.TurnIndex);
offset++; // Each insertion shifts subsequent indices by 1
compacted = true;
// Stop when target condition is met
if (this.Target(index))
{
break;
}
}
return Task.FromResult(compacted);
@@ -37,8 +37,12 @@ public sealed class TruncationCompactionStrategy : CompactionStrategy
/// 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)
/// <param name="target">
/// An optional target condition that controls when compaction stops. When <see langword="null"/>,
/// defaults to the inverse of the <paramref name="trigger"/> — compaction stops as soon as the trigger would no longer fire.
/// </param>
public TruncationCompactionStrategy(CompactionTrigger trigger, int preserveRecentGroups = DefaultPreserveRecentGroups, CompactionTrigger? target = null)
: base(trigger, target)
{
this.PreserveRecentGroups = preserveRecentGroups;
}
@@ -68,7 +72,7 @@ public sealed class TruncationCompactionStrategy : CompactionStrategy
return Task.FromResult(false);
}
// Exclude oldest non-system groups first (iterate from the beginning)
// Exclude oldest non-system groups one at a time, re-checking target after each
bool compacted = false;
int removed = 0;
for (int i = 0; i < index.Groups.Count && removed < maxRemovable; i++)
@@ -83,6 +87,12 @@ public sealed class TruncationCompactionStrategy : CompactionStrategy
group.ExcludeReason = $"Truncated by {nameof(TruncationCompactionStrategy)}";
removed++;
compacted = true;
// Stop when target condition is met
if (this.Target(index))
{
break;
}
}
return Task.FromResult(compacted);
@@ -75,13 +75,13 @@ public class TruncationCompactionStrategyTests
// Act
bool result = await strategy.CompactAsync(groups);
// Assert
// Assert — incremental: excludes until GroupsExceed(2) is no longer met → 2 groups remain
Assert.True(result);
Assert.Equal(1, groups.IncludedGroupCount);
// Oldest 3 excluded, newest 1 kept
Assert.Equal(2, groups.IncludedGroupCount);
// Oldest 2 excluded, newest 2 kept
Assert.True(groups.Groups[0].IsExcluded);
Assert.True(groups.Groups[1].IsExcluded);
Assert.True(groups.Groups[2].IsExcluded);
Assert.False(groups.Groups[2].IsExcluded);
Assert.False(groups.Groups[3].IsExcluded);
}