From 4c88d46e2be766f6b2bb8900e671373c7d897a98 Mon Sep 17 00:00:00 2001
From: arjendev <16266266+arjendev@users.noreply.github.com>
Date: Wed, 25 Mar 2026 17:57:50 +0100
Subject: [PATCH] .NET: Introduce ToolResultReductionStrategy
---
.../ToolResultCompactionStrategy.cs | 122 +---
.../Compaction/ToolResultReductionStrategy.cs | 250 +++++++
.../Compaction/ToolResultStrategyBase.cs | 224 +++++++
.../ToolResultReductionStrategyTests.cs | 608 ++++++++++++++++++
4 files changed, 1094 insertions(+), 110 deletions(-)
create mode 100644 dotnet/src/Microsoft.Agents.AI/Compaction/ToolResultReductionStrategy.cs
create mode 100644 dotnet/src/Microsoft.Agents.AI/Compaction/ToolResultStrategyBase.cs
create mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/ToolResultReductionStrategyTests.cs
diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/ToolResultCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/ToolResultCompactionStrategy.cs
index 6bb2ed26f6..5058a561b5 100644
--- a/dotnet/src/Microsoft.Agents.AI/Compaction/ToolResultCompactionStrategy.cs
+++ b/dotnet/src/Microsoft.Agents.AI/Compaction/ToolResultCompactionStrategy.cs
@@ -3,10 +3,7 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
-using System.Threading;
-using System.Threading.Tasks;
using Microsoft.Extensions.AI;
-using Microsoft.Extensions.Logging;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI.Compaction;
@@ -37,8 +34,9 @@ namespace Microsoft.Agents.AI.Compaction;
/// built-in default and can be reused inside a custom formatter when needed.
///
///
-/// is a hard floor: even if the
-/// has not been reached, compaction will not touch the last non-system groups.
+/// is a hard floor: even if the
+/// has not been reached, compaction will not touch the last
+/// non-system groups.
///
///
/// The predicate controls when compaction proceeds. Use
@@ -46,7 +44,7 @@ namespace Microsoft.Agents.AI.Compaction;
///
///
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
-public sealed class ToolResultCompactionStrategy : CompactionStrategy
+public sealed class ToolResultCompactionStrategy : ToolResultStrategyBase
{
///
/// The default minimum number of most-recent non-system groups to preserve.
@@ -73,17 +71,10 @@ public ToolResultCompactionStrategy(
CompactionTrigger trigger,
int minimumPreservedGroups = DefaultMinimumPreserved,
CompactionTrigger? target = null)
- : base(trigger, target)
+ : base(trigger, minimumPreservedGroups, target)
{
- this.MinimumPreservedGroups = EnsureNonNegative(minimumPreservedGroups);
}
- ///
- /// Gets the minimum number of most-recent non-system groups that are always preserved.
- /// This is a hard floor that compaction cannot exceed, regardless of the target condition.
- ///
- public int MinimumPreservedGroups { get; }
-
///
/// An optional custom formatter that converts a into a summary string.
/// When , is used, which produces a YAML-like
@@ -92,73 +83,15 @@ public ToolResultCompactionStrategy(
public Func? ToolCallFormatter { get; init; }
///
- protected override ValueTask CompactCoreAsync(CompactionMessageIndex index, ILogger logger, CancellationToken cancellationToken)
+ protected override (CompactionGroupKind Kind, List Messages, string ExcludeReason)
+ TransformToolGroup(CompactionMessageGroup group)
{
- // Identify protected groups: the N most-recent non-system, non-excluded groups
- List nonSystemIncludedIndices = [];
- for (int i = 0; i < index.Groups.Count; i++)
- {
- CompactionMessageGroup group = index.Groups[i];
- if (!group.IsExcluded && group.Kind != CompactionGroupKind.System)
- {
- nonSystemIncludedIndices.Add(i);
- }
- }
-
- int protectedStart = EnsureNonNegative(nonSystemIncludedIndices.Count - this.MinimumPreservedGroups);
- HashSet protectedGroupIndices = [];
- for (int i = protectedStart; i < nonSystemIncludedIndices.Count; i++)
- {
- protectedGroupIndices.Add(nonSystemIncludedIndices[i]);
- }
-
- // Collect eligible tool groups in order (oldest first)
- List eligibleIndices = [];
- for (int i = 0; i < index.Groups.Count; i++)
- {
- CompactionMessageGroup group = index.Groups[i];
- if (!group.IsExcluded && group.Kind == CompactionGroupKind.ToolCall && !protectedGroupIndices.Contains(i))
- {
- eligibleIndices.Add(i);
- }
- }
-
- if (eligibleIndices.Count == 0)
- {
- return new ValueTask(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;
- CompactionMessageGroup group = index.Groups[idx];
-
- string summary = (this.ToolCallFormatter ?? DefaultToolCallFormatter).Invoke(group);
+ string summary = (this.ToolCallFormatter ?? DefaultToolCallFormatter).Invoke(group);
- // Exclude the original group and insert a collapsed replacement
- group.IsExcluded = true;
- group.ExcludeReason = $"Collapsed by {nameof(ToolResultCompactionStrategy)}";
+ ChatMessage summaryMessage = new(ChatRole.Assistant, summary);
+ (summaryMessage.AdditionalProperties ??= [])[CompactionMessageGroup.SummaryPropertyKey] = true;
- ChatMessage summaryMessage = new(ChatRole.Assistant, summary);
- (summaryMessage.AdditionalProperties ??= [])[CompactionMessageGroup.SummaryPropertyKey] = true;
-
- index.InsertGroup(idx + 1, CompactionGroupKind.Summary, [summaryMessage], group.TurnIndex);
- offset++; // Each insertion shifts subsequent indices by 1
-
- compacted = true;
-
- // Stop when target condition is met
- if (this.Target(index))
- {
- break;
- }
- }
-
- return new ValueTask(compacted);
+ return (CompactionGroupKind.Summary, [summaryMessage], $"Collapsed by {nameof(ToolResultCompactionStrategy)}");
}
///
@@ -171,38 +104,7 @@ protected override ValueTask CompactCoreAsync(CompactionMessageIndex index
///
public static string DefaultToolCallFormatter(CompactionMessageGroup group)
{
- // Collect function calls (callId, name) and results (callId → result text)
- List<(string CallId, string Name)> functionCalls = [];
- Dictionary resultsByCallId = [];
- List plainTextResults = [];
-
- foreach (ChatMessage message in group.Messages)
- {
- if (message.Contents is null)
- {
- continue;
- }
-
- bool hasFunctionResult = false;
- foreach (AIContent content in message.Contents)
- {
- if (content is FunctionCallContent fcc)
- {
- functionCalls.Add((fcc.CallId, fcc.Name));
- }
- else if (content is FunctionResultContent frc && frc.CallId is not null)
- {
- resultsByCallId[frc.CallId] = frc.Result?.ToString() ?? string.Empty;
- hasFunctionResult = true;
- }
- }
-
- // Collect plain text from Tool-role messages that lack FunctionResultContent
- if (!hasFunctionResult && message.Role == ChatRole.Tool && message.Text is string text)
- {
- plainTextResults.Add(text);
- }
- }
+ var (functionCalls, resultsByCallId, plainTextResults) = ExtractToolCallsAndResults(group);
// Match function calls to their results using CallId or positional fallback,
// grouping by tool name while preserving first-seen order.
diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/ToolResultReductionStrategy.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/ToolResultReductionStrategy.cs
new file mode 100644
index 0000000000..2d1b6f2b8e
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI/Compaction/ToolResultReductionStrategy.cs
@@ -0,0 +1,250 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using Microsoft.Extensions.AI;
+using Microsoft.Shared.DiagnosticIds;
+using Microsoft.Shared.Diagnostics;
+
+namespace Microsoft.Agents.AI.Compaction;
+
+///
+/// A compaction strategy that applies per-tool reducers to individual tool results,
+/// preserving the original message structure (assistant tool calls + tool result messages)
+/// while transforming the result content.
+///
+///
+///
+/// Unlike which collapses entire tool call groups
+/// into a single YAML-like summary, this strategy keeps the tool call/result message pairing
+/// intact. Each is passed through its tool's registered
+/// reducer, and the group is replaced with a structurally identical group containing the
+/// reduced results.
+///
+///
+/// This is useful when a tool returns very large results (e.g., a retrieval API returning
+/// hundreds of thousands of tokens with relevance scores) that should be reduced before
+/// the model sees them — even on the current turn. The reducer can deserialize the result,
+/// filter, sort, and re-serialize it. These steps happen outside the framework; the framework
+/// only invokes the Func<object?, object?> delegate.
+///
+///
+/// Only groups that contain at least one tool name
+/// registered in are eligible. Groups with no registered tools
+/// are left untouched for other strategies to handle.
+///
+///
+/// For tools not registered in the dictionary within an otherwise eligible group, the raw
+/// result text is preserved as-is.
+///
+///
+/// defaults to 0 so that
+/// reducers apply to all tool results, including the current turn. Set a higher value to
+/// preserve recent results at full fidelity.
+///
+///
+/// This strategy composes naturally in a . A common
+/// pattern is to place it before — this strategy
+/// reduces result content while preserving structure, then the compaction strategy collapses
+/// older groups into concise summaries.
+///
+///
+[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
+public sealed class ToolResultReductionStrategy : ToolResultStrategyBase
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ ///
+ /// A dictionary mapping tool names to per-tool result reducers. Each reducer receives the
+ /// value for a single tool invocation and returns
+ /// the transformed result. The result type matches the object? type of
+ /// .
+ ///
+ ///
+ /// The that controls when reduction proceeds.
+ ///
+ ///
+ /// The minimum number of most-recent non-system message groups to preserve.
+ /// Defaults to 0 so that reducers apply to all tool results including the current turn.
+ ///
+ ///
+ /// An optional target condition that controls when reduction stops. When ,
+ /// defaults to the inverse of the .
+ ///
+ public ToolResultReductionStrategy(
+ IReadOnlyDictionary> toolResultReducers,
+ CompactionTrigger trigger,
+ int minimumPreservedGroups = 0,
+ CompactionTrigger? target = null)
+ : base(trigger, minimumPreservedGroups, target)
+ {
+ this.ToolResultReducers = Throw.IfNull(toolResultReducers);
+ }
+
+ ///
+ /// Gets the dictionary mapping tool names to per-tool result reducers.
+ /// Each reducer receives the value for a single
+ /// tool invocation and returns the transformed result.
+ ///
+ public IReadOnlyDictionary> ToolResultReducers { get; }
+
+ ///
+ /// Key used to mark tool groups that have already been reduced by this strategy,
+ /// preventing re-reduction on subsequent calls.
+ ///
+ internal const string ReducedPropertyKey = "_microsoft_agents_ai_compaction_tool_result_reduced";
+
+ ///
+ protected override bool IsToolGroupEligible(CompactionMessageGroup group)
+ {
+ if (this.ToolResultReducers.Count == 0)
+ {
+ return false;
+ }
+
+ // Skip groups already reduced by this strategy
+ foreach (ChatMessage message in group.Messages)
+ {
+ if (message.AdditionalProperties?.ContainsKey(ReducedPropertyKey) is true)
+ {
+ return false;
+ }
+ }
+
+ // Build CallId → tool name mapping to verify matching FunctionResultContent exists
+ Dictionary callIdToName = [];
+ foreach (ChatMessage message in group.Messages)
+ {
+ if (message.Contents is null)
+ {
+ continue;
+ }
+
+ foreach (AIContent content in message.Contents)
+ {
+ if (content is FunctionCallContent fcc && this.ToolResultReducers.ContainsKey(fcc.Name))
+ {
+ callIdToName[fcc.CallId] = fcc.Name;
+ }
+ }
+ }
+
+ if (callIdToName.Count == 0)
+ {
+ return false;
+ }
+
+ // Verify at least one FunctionResultContent matches a registered tool call
+ foreach (ChatMessage message in group.Messages)
+ {
+ if (message.Contents is null)
+ {
+ continue;
+ }
+
+ foreach (AIContent content in message.Contents)
+ {
+ if (content is FunctionResultContent frc
+ && frc.CallId is not null
+ && callIdToName.ContainsKey(frc.CallId))
+ {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ ///
+ protected override (CompactionGroupKind Kind, List Messages, string ExcludeReason)
+ TransformToolGroup(CompactionMessageGroup group)
+ {
+ // Build a CallId → tool name mapping from the shared extraction helper
+ var (functionCalls, _, _) = ExtractToolCallsAndResults(group);
+
+ Dictionary callIdToName = [];
+ foreach ((string callId, string name) in functionCalls)
+ {
+ callIdToName[callId] = name;
+ }
+
+ // Rebuild messages with reduced FunctionResultContent
+ List reducedMessages = [];
+ foreach (ChatMessage message in group.Messages)
+ {
+ if (message.Contents is null || message.Contents.Count == 0)
+ {
+ reducedMessages.Add(message);
+ continue;
+ }
+
+ bool hasReduction = false;
+ List newContents = [];
+
+ foreach (AIContent content in message.Contents)
+ {
+ if (content is FunctionResultContent frc
+ && frc.CallId is not null
+ && callIdToName.TryGetValue(frc.CallId, out string? toolName)
+ && this.ToolResultReducers.TryGetValue(toolName, out Func