Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -58,14 +65,24 @@ public sealed class ToolResultCompactionStrategy : CompactionStrategy
/// regardless of the target condition.
/// Defaults to <see cref="DefaultMinimumPreserved"/>, ensuring the current turn's tool interactions remain visible.
/// </param>
/// <param name="toolCallFormatter">
/// 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.
/// </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 ToolResultCompactionStrategy(CompactionTrigger trigger, int minimumPreservedGroups = DefaultMinimumPreserved, CompactionTrigger? target = null)
public ToolResultCompactionStrategy(
CompactionTrigger trigger,
int minimumPreservedGroups = DefaultMinimumPreserved,
Func<CompactionMessageGroup, string>? toolCallFormatter = null,
CompactionTrigger? target = null)
: base(trigger, target)
{
this.MinimumPreservedGroups = EnsureNonNegative(minimumPreservedGroups);
this.ToolCallFormatter = toolCallFormatter ?? DefaultToolCallFormatter;
}

/// <summary>
Expand All @@ -74,6 +91,15 @@ public ToolResultCompactionStrategy(CompactionTrigger trigger, int minimumPreser
/// </summary>
public int MinimumPreservedGroups { get; }

/// <summary>
/// Gets the formatter used to convert a <see cref="CompactionMessageGroup"/> into a summary string.
/// </summary>
/// <remarks>
/// Defaults to <see cref="DefaultToolCallFormatter"/>. Supply a custom function via the constructor
/// to override the format of collapsed tool call groups.
/// </remarks>
public Func<CompactionMessageGroup, string> ToolCallFormatter { get; }

/// <inheritdoc/>
protected override ValueTask<bool> CompactCoreAsync(CompactionMessageIndex index, ILogger logger, CancellationToken cancellationToken)
{
Expand Down Expand Up @@ -120,7 +146,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(group);

// Exclude the original group and insert a collapsed replacement
group.IsExcluded = true;
Expand All @@ -145,10 +171,14 @@ 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 = [];
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,84 @@ 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 ToolCallFormatterPropertyIsNotNullWhenNoneProvided()
{
// Arrange
ToolResultCompactionStrategy strategy = new(CompactionTriggers.Always);

// Assert — ToolCallFormatter is not null when no custom formatter is provided
Assert.NotNull(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);
}
}