// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Compaction;
///
/// Base class for strategies that compact a to reduce context size.
///
///
///
/// Compaction strategies operate on 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).
///
///
/// Every strategy requires a that determines whether compaction should
/// proceed based on current metrics (token count, message count, turn count, etc.).
/// The base class evaluates this trigger at the start of and skips compaction when
/// the trigger returns .
///
///
/// An optional target condition controls when compaction stops. Strategies incrementally exclude
/// groups and re-evaluate the target after each exclusion, stopping as soon as the target returns
/// . When no target is specified, it defaults to the inverse of the trigger —
/// meaning compaction stops when the trigger condition would no longer fire.
///
///
/// Strategies can be applied at three lifecycle points:
///
/// - In-run: During the tool loop, before each LLM call, to keep context within token limits.
/// - Pre-write: Before persisting messages to storage via .
/// - On existing storage: As a maintenance operation to compact stored history.
///
///
///
/// Multiple strategies can be composed by applying them sequentially to the same
/// via .
///
///
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public abstract class CompactionStrategy
{
///
/// Initializes a new instance of the class.
///
///
/// The that determines whether compaction should proceed.
///
///
/// An optional target condition that controls when compaction stops. Strategies re-evaluate
/// this predicate after each incremental exclusion and stop when it returns .
/// When , defaults to the inverse of the — compaction
/// stops as soon as the trigger condition would no longer fire.
///
protected CompactionStrategy(CompactionTrigger trigger, CompactionTrigger? target = null)
{
this.Trigger = Throw.IfNull(trigger);
this.Target = target ?? (index => !trigger(index));
}
///
/// Gets the trigger predicate that controls when compaction proceeds.
///
protected CompactionTrigger Trigger { get; }
///
/// Gets the target predicate that controls when compaction stops.
/// Strategies re-evaluate this after each incremental exclusion and stop when it returns .
///
protected CompactionTrigger Target { get; }
///
/// Applies the strategy-specific compaction logic to the specified message index.
///
///
/// This method is called by only when the
/// returns . Implementations do not need to evaluate the trigger or
/// report metrics — the base class handles both. Implementations should use
/// to determine when to stop compacting incrementally.
///
/// The message index to compact. The strategy mutates this collection in place.
/// The for emitting compaction diagnostics.
/// The to monitor for cancellation requests.
/// A task whose result is if any compaction was performed, otherwise.
protected abstract ValueTask CompactCoreAsync(CompactionMessageIndex index, ILogger logger, CancellationToken cancellationToken);
///
/// Evaluates the and, when it fires, delegates to
/// and reports compaction metrics.
///
/// The message index to compact. The strategy mutates this collection in place.
/// An optional for emitting compaction diagnostics. When , logging is disabled.
/// The to monitor for cancellation requests.
/// A task representing the asynchronous operation. The task result is if compaction occurred, otherwise.
public async ValueTask CompactAsync(CompactionMessageIndex index, ILogger? logger = null, CancellationToken cancellationToken = default)
{
string strategyName = this.GetType().Name;
logger ??= NullLogger.Instance;
using Activity? activity = CompactionTelemetry.ActivitySource.StartActivity(CompactionTelemetry.ActivityNames.Compact);
activity?.SetTag(CompactionTelemetry.Tags.Strategy, strategyName);
if (index.IncludedNonSystemGroupCount <= 1 || !this.Trigger(index))
{
activity?.SetTag(CompactionTelemetry.Tags.Triggered, false);
logger.LogCompactionSkipped(strategyName);
return false;
}
activity?.SetTag(CompactionTelemetry.Tags.Triggered, true);
int beforeTokens = index.IncludedTokenCount;
int beforeGroups = index.IncludedGroupCount;
int beforeMessages = index.IncludedMessageCount;
Stopwatch stopwatch = Stopwatch.StartNew();
bool compacted = await this.CompactCoreAsync(index, logger, cancellationToken).ConfigureAwait(false);
stopwatch.Stop();
activity?.SetTag(CompactionTelemetry.Tags.Compacted, compacted);
if (compacted)
{
activity?
.SetTag(CompactionTelemetry.Tags.BeforeTokens, beforeTokens)
.SetTag(CompactionTelemetry.Tags.AfterTokens, index.IncludedTokenCount)
.SetTag(CompactionTelemetry.Tags.BeforeMessages, beforeMessages)
.SetTag(CompactionTelemetry.Tags.AfterMessages, index.IncludedMessageCount)
.SetTag(CompactionTelemetry.Tags.BeforeGroups, beforeGroups)
.SetTag(CompactionTelemetry.Tags.AfterGroups, index.IncludedGroupCount)
.SetTag(CompactionTelemetry.Tags.DurationMs, stopwatch.ElapsedMilliseconds);
logger.LogCompactionCompleted(
strategyName,
stopwatch.ElapsedMilliseconds,
beforeMessages,
index.IncludedMessageCount,
beforeGroups,
index.IncludedGroupCount,
beforeTokens,
index.IncludedTokenCount);
}
return compacted;
}
///
/// Ensures the provided value is not a negative number.
///
/// The target value.
/// 0 if negative; otherwise the value
protected static int EnsureNonNegative(int value) => Math.Max(0, value);
}