Skip to content
Merged
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.

using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Threading;
Expand Down Expand Up @@ -30,6 +31,12 @@ namespace Microsoft.Agents.AI.Compaction;
/// </code>
/// </para>
/// <para>
/// A custom <see cref="ToolCallFormatter"/> can be supplied to override the default YAML-like
/// summary format. The formatter receives the <see cref="CompactionMessageGroup"/> being collapsed
/// and must return the replacement summary string. <see cref="DefaultToolCallFormatter"/> is the
/// built-in default and can be reused inside a custom formatter when needed.
/// </para>
/// <para>
/// <see cref="MinimumPreservedGroups"/> is a hard floor: even if the <see cref="CompactionStrategy.Target"/>
/// has not been reached, compaction will not touch the last <see cref="MinimumPreservedGroups"/> non-system groups.
/// </para>
Expand Down Expand Up @@ -62,7 +69,10 @@ public sealed class ToolResultCompactionStrategy : CompactionStrategy
/// 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 minimumPreservedGroups = DefaultMinimumPreserved, CompactionTrigger? target = null)
public ToolResultCompactionStrategy(
CompactionTrigger trigger,
int minimumPreservedGroups = DefaultMinimumPreserved,
CompactionTrigger? target = null)
: base(trigger, target)
{
this.MinimumPreservedGroups = EnsureNonNegative(minimumPreservedGroups);
Expand All @@ -74,6 +84,13 @@ public ToolResultCompactionStrategy(CompactionTrigger trigger, int minimumPreser
/// </summary>
public int MinimumPreservedGroups { get; }

/// <summary>
/// An optional custom formatter that converts a <see cref="CompactionMessageGroup"/> into a summary string.
/// When <see langword="null"/>, <see cref="DefaultToolCallFormatter"/> is used, which produces a YAML-like
/// block listing each tool name and its results.
/// </summary>
public Func<CompactionMessageGroup, string>? ToolCallFormatter { get; init; }

/// <inheritdoc/>
protected override ValueTask<bool> CompactCoreAsync(CompactionMessageIndex index, ILogger logger, CancellationToken cancellationToken)
{
Expand Down Expand Up @@ -120,7 +137,7 @@ protected override ValueTask<bool> CompactCoreAsync(CompactionMessageIndex index
int idx = eligibleIndices[e] + offset;
CompactionMessageGroup group = index.Groups[idx];

string summary = BuildToolCallSummary(group);
string summary = (this.ToolCallFormatter ?? DefaultToolCallFormatter).Invoke(group);

// Exclude the original group and insert a collapsed replacement
group.IsExcluded = true;
Expand All @@ -145,14 +162,18 @@ protected override ValueTask<bool> CompactCoreAsync(CompactionMessageIndex index
}

/// <summary>
/// Builds a concise summary string for a tool call group, including tool names,
/// The default formatter that produces a YAML-like summary of tool call groups, including tool names,
/// results, and deduplication counts for repeated tool names.
/// </summary>
private static string BuildToolCallSummary(CompactionMessageGroup group)
/// <remarks>
/// This is the formatter used when no custom <see cref="ToolCallFormatter"/> is supplied.
/// It can be referenced directly in a custom formatter to augment or wrap the default output.
/// </remarks>
public static string DefaultToolCallFormatter(CompactionMessageGroup group)
{
// Collect function calls (callId, name) and results (callId → result text)
List<(string CallId, string Name)> functionCalls = [];
Dictionary<string, string> resultsByCallId = new();
Dictionary<string, string> resultsByCallId = [];
List<string> plainTextResults = [];

foreach (ChatMessage message in group.Messages)
Expand Down Expand Up @@ -187,7 +208,7 @@ private static string BuildToolCallSummary(CompactionMessageGroup group)
// grouping by tool name while preserving first-seen order.
int plainTextIdx = 0;
List<string> orderedNames = [];
Dictionary<string, List<string>> groupedResults = new();
Dictionary<string, List<string>> groupedResults = [];

foreach ((string callId, string name) in functionCalls)
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.

using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Compaction;
Expand Down Expand Up @@ -348,4 +349,90 @@ public async Task CompactAsyncDeduplicatesWithFunctionResultContentAsync()
List<ChatMessage> included = [.. groups.GetIncludedMessages()];
Assert.Equal("[Tool Calls]\nget_weather:\n - Sunny\n - Rainy\nsearch_docs:\n - Found 3 docs", included[1].Text);
}

[Fact]
public async Task CompactAsyncUsesCustomFormatterAsync()
{
// Arrange — custom formatter that produces a collapsed message count
static string CustomFormatter(CompactionMessageGroup group) =>
$"[Collapsed: {group.Messages.Count} messages]";

ToolResultCompactionStrategy strategy = new(
trigger: _ => true,
minimumPreservedGroups: 1)
{
ToolCallFormatter = CustomFormatter,
};

CompactionMessageIndex groups = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "get_weather")]),
new ChatMessage(ChatRole.Tool, "Sunny"),
new ChatMessage(ChatRole.User, "Q2"),
]);

// Act
bool result = await strategy.CompactAsync(groups);

// Assert — custom formatter output used instead of default YAML-like format
Assert.True(result);
List<ChatMessage> included = [.. groups.GetIncludedMessages()];
Assert.Equal("[Collapsed: 2 messages]", included[1].Text);
}

[Fact]
public void ToolCallFormatterPropertyIsNullWhenNoneProvided()
{
// Arrange
ToolResultCompactionStrategy strategy = new(CompactionTriggers.Always);

// Assert — ToolCallFormatter is null when no custom formatter is provided
Assert.Null(strategy.ToolCallFormatter);
}

[Fact]
public void ToolCallFormatterPropertyReturnsCustomFormatterWhenProvided()
{
// Arrange
Func<CompactionMessageGroup, string> customFormatter = static _ => "custom";
ToolResultCompactionStrategy strategy = new(
CompactionTriggers.Always)
{
ToolCallFormatter = customFormatter
};

// Assert — ToolCallFormatter is the injected custom function
Assert.Same(customFormatter, strategy.ToolCallFormatter);
}

[Fact]
public async Task CompactAsyncCustomFormatterCanDelegateToDefaultAsync()
{
// Arrange — custom formatter that wraps the default output
static string WrappingFormatter(CompactionMessageGroup group) =>
$"CUSTOM_PREFIX\n{ToolResultCompactionStrategy.DefaultToolCallFormatter(group)}";

ToolResultCompactionStrategy strategy = new(
trigger: _ => true,
minimumPreservedGroups: 1)
{
ToolCallFormatter = WrappingFormatter
};

CompactionMessageIndex groups = CompactionMessageIndex.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
await strategy.CompactAsync(groups);

// Assert — wrapped default output
List<ChatMessage> included = [.. groups.GetIncludedMessages()];
Assert.Equal("CUSTOM_PREFIX\n[Tool Calls]\nfn:\n - result", included[1].Text);
}
}
Loading