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? reducer)) + { + newContents.Add(CloneWithReducedResult(frc, reducer(frc.Result))); + hasReduction = true; + } + else + { + newContents.Add(content); + } + } + + if (hasReduction) + { + ChatMessage reducedMessage = message.Clone(); + reducedMessage.Contents = newContents; + (reducedMessage.AdditionalProperties ??= [])[ReducedPropertyKey] = true; + reducedMessages.Add(reducedMessage); + } + else + { + reducedMessages.Add(message.Clone()); + } + } + + return (CompactionGroupKind.ToolCall, reducedMessages, $"Reduced by {nameof(ToolResultReductionStrategy)}"); + } + + /// + /// Creates a new with the reduced result while + /// preserving all metadata (, + /// , ) + /// from the original. + /// + private static FunctionResultContent CloneWithReducedResult(FunctionResultContent original, object? reducedResult) + { + var clone = new FunctionResultContent(original.CallId, reducedResult) + { + RawRepresentation = original.RawRepresentation, + }; + + if (original.Annotations is { Count: > 0 }) + { + foreach (var annotation in original.Annotations) + { + (clone.Annotations ??= []).Add(annotation); + } + } + + if (original.AdditionalProperties is { Count: > 0 }) + { + foreach (var kvp in original.AdditionalProperties) + { + (clone.AdditionalProperties ??= [])[kvp.Key] = kvp.Value; + } + } + + return clone; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/ToolResultStrategyBase.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/ToolResultStrategyBase.cs new file mode 100644 index 0000000000..cad666c1e5 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/ToolResultStrategyBase.cs @@ -0,0 +1,224 @@ +// Copyright (c) Microsoft. All rights reserved. + +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; + +/// +/// Base class for compaction strategies that operate on +/// groups, providing the shared logic for protected-group identification, eligible-group collection, +/// and the exclude-and-insert processing loop. +/// +/// +/// +/// Subclasses customize behavior through two hook methods: +/// +/// +/// — determines whether a specific tool call group should be processed. +/// The default returns for all tool call groups. +/// +/// +/// — transforms an eligible group into replacement messages. +/// Returns the replacement group kind, messages, and an exclude reason for diagnostics. +/// +/// +/// +/// +/// is a hard floor: even if the +/// has not been reached, processing will not touch the last non-system groups. +/// +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public abstract class ToolResultStrategyBase : CompactionStrategy +{ + /// + /// Initializes a new instance of the class. + /// + /// + /// The that controls when processing proceeds. + /// + /// + /// The minimum number of most-recent non-system message groups to preserve. + /// This is a hard floor — processing will not touch groups beyond this limit, + /// regardless of the target condition. + /// + /// + /// An optional target condition that controls when processing stops. When , + /// defaults to the inverse of the . + /// + protected ToolResultStrategyBase( + CompactionTrigger trigger, + int minimumPreservedGroups, + CompactionTrigger? target = null) + : base(trigger, 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 processing cannot exceed, regardless of the target condition. + /// + public int MinimumPreservedGroups { get; } + + /// + /// Determines whether the specified group should be + /// processed by this strategy. Called for each non-excluded, non-protected tool call group. + /// + /// The tool call group to evaluate. + /// + /// if the group should be processed; to skip it. + /// + /// + /// The default implementation returns for all groups. + /// Override to add filtering logic such as checking for specific tool names. + /// + protected virtual bool IsToolGroupEligible(CompactionMessageGroup group) => true; + + /// + /// Transforms an eligible tool call group into replacement messages. + /// + /// The tool call group to transform. + /// + /// A tuple containing: + /// + /// Kind — the for the replacement group. + /// Messages — the replacement messages to insert. + /// ExcludeReason — a diagnostic string describing why the original group was excluded. + /// + /// + protected abstract (CompactionGroupKind Kind, List Messages, string ExcludeReason) + TransformToolGroup(CompactionMessageGroup group); + + /// + protected sealed override ValueTask CompactCoreAsync( + CompactionMessageIndex index, + ILogger logger, + CancellationToken cancellationToken) + { + // 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)) + { + continue; + } + + if (!this.IsToolGroupEligible(group)) + { + continue; + } + + eligibleIndices.Add(i); + } + + if (eligibleIndices.Count == 0) + { + return new ValueTask(false); + } + + // Process one tool group at a time from oldest, re-checking target after each + bool processed = false; + int offset = 0; + + for (int e = 0; e < eligibleIndices.Count; e++) + { + cancellationToken.ThrowIfCancellationRequested(); + + int idx = eligibleIndices[e] + offset; + CompactionMessageGroup group = index.Groups[idx]; + + var (kind, messages, excludeReason) = this.TransformToolGroup(group); + + group.IsExcluded = true; + group.ExcludeReason = excludeReason; + + index.InsertGroup(idx + 1, kind, messages, group.TurnIndex); + offset++; + + processed = true; + + if (this.Target(index)) + { + break; + } + } + + return new ValueTask(processed); + } + + /// + /// Extracts function calls, their results, and plain-text tool results from a + /// . + /// + /// + /// items are collected as ordered (CallId, Name) pairs, + /// preserving first-seen order for downstream formatting. + /// items are collected by CallId for lookup-based matching. Plain-text Tool-role messages + /// that lack are collected separately for positional fallback. + /// + protected static ( + List<(string CallId, string Name)> FunctionCalls, + Dictionary ResultsByCallId, + List PlainTextResults) ExtractToolCallsAndResults(CompactionMessageGroup group) + { + List<(string CallId, string Name)> functionCalls = []; + Dictionary resultsByCallId = []; + List plainTextResults = []; + + foreach (ChatMessage message in group.Messages) + { + bool hasFunctionResult = false; + + if (message.Contents is not null) + { + 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); + } + } + + return (functionCalls, resultsByCallId, plainTextResults); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/ToolResultReductionStrategyTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/ToolResultReductionStrategyTests.cs new file mode 100644 index 0000000000..d1fc278c06 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/ToolResultReductionStrategyTests.cs @@ -0,0 +1,608 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Compaction; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.UnitTests.Compaction; + +/// +/// Contains tests for the class. +/// +public partial class ToolResultReductionStrategyTests +{ + [Fact] + public async Task CompactAsyncTriggerNotMetReturnsFalseAsync() + { + // Arrange — trigger requires > 1000 tokens + ToolResultReductionStrategy strategy = new( + new Dictionary> + { + ["fn"] = result => "reduced", + }, + CompactionTriggers.TokensExceed(1000)); + + CompactionMessageIndex index = CompactionMessageIndex.Create( + [ + new ChatMessage(ChatRole.User, "Q1"), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "fn")]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c1", "short")]), + ]); + + // Act + bool result = await strategy.CompactAsync(index); + + // Assert + Assert.False(result); + } + + [Fact] + public async Task CompactAsyncAppliesReducerToRegisteredToolAsync() + { + // Arrange — register a reducer that uppercases weather results + ToolResultReductionStrategy strategy = new( + new Dictionary> + { + ["get_weather"] = result => result?.ToString()!.ToUpperInvariant(), + }, + trigger: _ => true); + + CompactionMessageIndex index = CompactionMessageIndex.Create( + [ + new ChatMessage(ChatRole.User, "Q1"), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "get_weather")]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c1", "Sunny and 72°F")]), + ]); + + // Act + bool result = await strategy.CompactAsync(index); + + // Assert — result reduced but message structure preserved + Assert.True(result); + + List included = [.. index.GetIncludedMessages()]; + Assert.Equal(3, included.Count); + Assert.Equal(ChatRole.User, included[0].Role); + Assert.Equal(ChatRole.Assistant, included[1].Role); + Assert.Equal(ChatRole.Tool, included[2].Role); + + FunctionResultContent? frc = included[2].Contents.OfType().SingleOrDefault(); + Assert.NotNull(frc); + Assert.Equal("SUNNY AND 72°F", frc.Result?.ToString()); + } + + [Fact] + public async Task CompactAsyncPreservesUnregisteredToolResultsAsync() + { + // Arrange — reducer registered for search_docs only; get_weather is unregistered + ToolResultReductionStrategy strategy = new( + new Dictionary> + { + ["search_docs"] = result => $"({result?.ToString()!.Split('\n').Length} results)", + }, + trigger: _ => true); + + ChatMessage multiToolCall = new(ChatRole.Assistant, + [ + new FunctionCallContent("c1", "search_docs"), + new FunctionCallContent("c2", "get_weather"), + ]); + + CompactionMessageIndex index = CompactionMessageIndex.Create( + [ + new ChatMessage(ChatRole.User, "Q1"), + multiToolCall, + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c1", "doc1\ndoc2\ndoc3")]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c2", "Sunny")]), + ]); + + // Act + await strategy.CompactAsync(index); + + // Assert — search_docs reduced, get_weather preserved as-is + List included = [.. index.GetIncludedMessages()]; + Assert.Equal(4, included.Count); + + FunctionResultContent searchResult = included[2].Contents.OfType().Single(); + Assert.Equal("(3 results)", searchResult.Result?.ToString()); + + FunctionResultContent weatherResult = included[3].Contents.OfType().Single(); + Assert.Equal("Sunny", weatherResult.Result?.ToString()); + } + + [Fact] + public async Task CompactAsyncPreservesMessageStructureAsync() + { + // Arrange — reducer that truncates long results + ToolResultReductionStrategy strategy = new( + new Dictionary> + { + ["retrieval_api"] = result => { var s = result?.ToString()!; return s.Length > 20 ? s[..20] + "..." : s; }, + }, + trigger: _ => true); + + CompactionMessageIndex index = CompactionMessageIndex.Create( + [ + new ChatMessage(ChatRole.User, "Find relevant documents"), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "retrieval_api")]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c1", "This is a very long result that exceeds twenty characters")]), + ]); + + // Act + await strategy.CompactAsync(index); + + // Assert — structure preserved: User + Assistant (with FunctionCallContent) + Tool (with FunctionResultContent) + List included = [.. index.GetIncludedMessages()]; + Assert.Equal(3, included.Count); + Assert.Equal(ChatRole.Assistant, included[1].Role); + Assert.IsType(included[1].Contents[0]); + Assert.Equal(ChatRole.Tool, included[2].Role); + + FunctionResultContent frc = included[2].Contents.OfType().Single(); + Assert.Equal("c1", frc.CallId); + Assert.Equal("This is a very long ...", frc.Result?.ToString()); + } + + [Fact] + public async Task CompactAsyncReducesCurrentTurnByDefaultAsync() + { + // Arrange — minimumPreservedGroups defaults to 0, so current turn is eligible + ToolResultReductionStrategy strategy = new( + new Dictionary> + { + ["fn"] = result => result?.ToString()!.ToUpperInvariant(), + }, + trigger: _ => true); + + CompactionMessageIndex index = CompactionMessageIndex.Create( + [ + new ChatMessage(ChatRole.User, "Q1"), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "fn")]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c1", "hello")]), + ]); + + // Act + bool result = await strategy.CompactAsync(index); + + // Assert — even the only/current tool group was reduced + Assert.True(result); + List included = [.. index.GetIncludedMessages()]; + FunctionResultContent frc = included[2].Contents.OfType().Single(); + Assert.Equal("HELLO", frc.Result?.ToString()); + } + + [Fact] + public async Task CompactAsyncPreservesRecentToolGroupsAsync() + { + // Arrange — protect 3 recent groups (tool group + user message = protected) + ToolResultReductionStrategy strategy = new( + new Dictionary> + { + ["fn"] = result => "SHOULD NOT APPEAR", + }, + trigger: _ => true, + minimumPreservedGroups: 3); + + CompactionMessageIndex index = CompactionMessageIndex.Create( + [ + new ChatMessage(ChatRole.User, "Q1"), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "fn")]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c1", "result")]), + new ChatMessage(ChatRole.User, "Q2"), + ]); + + // Act + bool result = await strategy.CompactAsync(index); + + // Assert — all groups in protected window, nothing reduced + Assert.False(result); + } + + [Fact] + public async Task CompactAsyncOnlyTargetsGroupsWithRegisteredReducersAsync() + { + // Arrange — reducer for search_docs only; get_weather-only group is skipped + ToolResultReductionStrategy strategy = new( + new Dictionary> + { + ["search_docs"] = result => "reduced", + }, + trigger: _ => true); + + CompactionMessageIndex index = CompactionMessageIndex.Create( + [ + new ChatMessage(ChatRole.User, "Q1"), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "get_weather")]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c1", "Sunny")]), + new ChatMessage(ChatRole.User, "Q2"), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c2", "search_docs")]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c2", "doc1")]), + ]); + + // Act + bool result = await strategy.CompactAsync(index); + + // Assert — only search_docs group reduced; get_weather group untouched + Assert.True(result); + + int reducedToolGroups = 0; + int preservedToolGroups = 0; + foreach (CompactionMessageGroup group in index.Groups) + { + if (group.Kind == CompactionGroupKind.ToolCall) + { + if (group.IsExcluded) + { + reducedToolGroups++; + } + else + { + preservedToolGroups++; + } + } + } + + Assert.Equal(1, reducedToolGroups); + Assert.Equal(2, preservedToolGroups); // get_weather unchanged + search_docs reduced replacement + } + + [Fact] + public async Task CompactAsyncTargetStopsReducingEarlyAsync() + { + // Arrange — 2 eligible tool groups, target met after first reduction + int reduceCount = 0; + bool TargetAfterOne(CompactionMessageIndex _) => ++reduceCount >= 1; + + ToolResultReductionStrategy strategy = new( + new Dictionary> + { + ["fn1"] = result => "reduced1", + ["fn2"] = result => "reduced2", + }, + trigger: _ => true, + target: TargetAfterOne); + + CompactionMessageIndex index = CompactionMessageIndex.Create( + [ + new ChatMessage(ChatRole.User, "Q1"), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "fn1")]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c1", "result1")]), + new ChatMessage(ChatRole.User, "Q2"), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c2", "fn2")]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c2", "result2")]), + ]); + + // Act + bool result = await strategy.CompactAsync(index); + + // Assert — only first tool group reduced + Assert.True(result); + + int reducedToolGroups = 0; + foreach (CompactionMessageGroup group in index.Groups) + { + if (group.IsExcluded && group.Kind == CompactionGroupKind.ToolCall) + { + reducedToolGroups++; + } + } + + Assert.Equal(1, reducedToolGroups); + } + + [Fact] + public async Task CompactAsyncPreservesSystemMessagesAsync() + { + // Arrange + ToolResultReductionStrategy strategy = new( + new Dictionary> + { + ["fn"] = result => "reduced", + }, + trigger: _ => true); + + CompactionMessageIndex index = CompactionMessageIndex.Create( + [ + new ChatMessage(ChatRole.System, "You are helpful."), + new ChatMessage(ChatRole.User, "Q1"), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "fn")]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c1", "result")]), + ]); + + // Act + await strategy.CompactAsync(index); + + // Assert + List included = [.. index.GetIncludedMessages()]; + Assert.Equal("You are helpful.", included[0].Text); + Assert.False(index.Groups[0].IsExcluded); + } + + [Fact] + public async Task CompactAsyncNoToolGroupsReturnsFalseAsync() + { + // Arrange — trigger fires but no tool groups to reduce + ToolResultReductionStrategy strategy = new( + new Dictionary> + { + ["fn"] = result => "reduced", + }, + trigger: _ => true); + + CompactionMessageIndex index = CompactionMessageIndex.Create( + [ + new ChatMessage(ChatRole.User, "Hello"), + new ChatMessage(ChatRole.Assistant, "Hi!"), + ]); + + // Act + bool result = await strategy.CompactAsync(index); + + // Assert + Assert.False(result); + } + + [Fact] + public async Task CompactAsyncSkipsPreExcludedAndSystemGroupsAsync() + { + // Arrange — pre-excluded and system groups in the enumeration + ToolResultReductionStrategy strategy = new( + new Dictionary> + { + ["fn"] = result => "reduced", + }, + CompactionTriggers.Always); + + List messages = + [ + new ChatMessage(ChatRole.System, "System prompt"), + new ChatMessage(ChatRole.User, "Q0"), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "fn")]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c1", "Result 1")]), + new ChatMessage(ChatRole.User, "Q1"), + ]; + + CompactionMessageIndex index = CompactionMessageIndex.Create(messages); + // Pre-exclude the last user group + index.Groups[index.Groups.Count - 1].IsExcluded = true; + + // Act + bool result = await strategy.CompactAsync(index); + + // Assert — system never excluded, pre-excluded skipped + Assert.True(result); + Assert.False(index.Groups[0].IsExcluded); // System stays + } + + [Fact] + public async Task CompactAsyncComposesWithCompactionInPipelineAsync() + { + // Arrange — reduction first, then compaction in a pipeline + ToolResultReductionStrategy reductionStrategy = new( + new Dictionary> + { + ["search"] = result => $"[{result?.ToString()!.Split('\n').Length} results]", + }, + trigger: _ => true); + + ToolResultCompactionStrategy compactionStrategy = new( + trigger: _ => true, + minimumPreservedGroups: 1); + + PipelineCompactionStrategy pipeline = new(reductionStrategy, compactionStrategy); + + CompactionMessageIndex index = CompactionMessageIndex.Create( + [ + new ChatMessage(ChatRole.User, "Q1"), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "search")]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c1", "doc1\ndoc2\ndoc3")]), + new ChatMessage(ChatRole.User, "Q2"), + ]); + + // Act + await pipeline.CompactAsync(index); + + // Assert — reduction applied first (3 results), then compacted to YAML + List included = [.. index.GetIncludedMessages()]; + Assert.Equal(3, included.Count); // Q1, collapsed summary, Q2 + Assert.Contains("[3 results]", included[1].Text); + Assert.Contains("[Tool Calls]", included[1].Text); + } + + [Fact] + public async Task CompactAsyncRetrievalApiSelectsHighestRelevantWithinBudgetAsync() + { + // Arrange — simulate a retrieval API returning JSON chunks with relevance scores. + // The reducer deserializes, orders by relevance, selects top chunks within a + // character budget, and re-serializes — the canonical RAG compaction scenario. + const string RetrievalResult = + """ + [ + {"chunk": "Irrelevant filler content that wastes tokens", "relevance": 0.2}, + {"chunk": "Highly relevant answer about the product pricing model", "relevance": 0.95}, + {"chunk": "Somewhat relevant context about product history", "relevance": 0.6}, + {"chunk": "Very relevant details about current pricing tiers", "relevance": 0.9}, + {"chunk": "Noise from unrelated document section", "relevance": 0.1}, + {"chunk": "Moderately relevant competitor comparison", "relevance": 0.7} + ] + """; + + // Only keep chunks whose combined text fits within ~100 chars. + // Top-2 by relevance: 0.95 (55 chars) + 0.90 (49 chars) = 104 chars — over budget. + // So only the top-1 (0.95) fits. + const int CharBudget = 100; + + ToolResultReductionStrategy strategy = new( + new Dictionary> + { + ["retrieval_api"] = result => + { + var chunks = JsonSerializer.Deserialize(result?.ToString()!, ChunkSerializerContext.Default.ListChunkResult)!; + var selected = new List(); + int totalChars = 0; + + foreach (var chunk in chunks.OrderByDescending(c => c.Relevance)) + { + if (totalChars + chunk.Chunk.Length > CharBudget) + { + break; + } + + selected.Add(chunk); + totalChars += chunk.Chunk.Length; + } + + return JsonSerializer.Serialize(selected, ChunkSerializerContext.Default.ListChunkResult); + }, + }, + trigger: _ => true); + + CompactionMessageIndex index = CompactionMessageIndex.Create( + [ + new ChatMessage(ChatRole.User, "What are the pricing tiers?"), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "retrieval_api")]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c1", RetrievalResult)]), + ]); + + // Act + bool result = await strategy.CompactAsync(index); + + // Assert — reduced to only the highest-relevance chunk(s) within budget + Assert.True(result); + + List included = [.. index.GetIncludedMessages()]; + Assert.Equal(3, included.Count); + Assert.Equal(ChatRole.Tool, included[2].Role); + + FunctionResultContent frc = included[2].Contents.OfType().Single(); + string reducedJson = frc.Result!.ToString()!; + + var reducedChunks = JsonSerializer.Deserialize(reducedJson, ChunkSerializerContext.Default.ListChunkResult)!; + + // Should contain only the top chunk(s) that fit the budget + Assert.True(reducedChunks.Count >= 1, "Should select at least one chunk"); + Assert.True(reducedChunks.All(c => c.Relevance >= 0.9), "Should only contain high-relevance chunks"); + Assert.Equal(0.95, reducedChunks[0].Relevance); + + // Verify total is within budget + int totalLength = reducedChunks.Sum(c => c.Chunk.Length); + Assert.True(totalLength <= CharBudget, $"Total chunk length {totalLength} should be within budget {CharBudget}"); + } + + [Fact] + public async Task CompactAsyncPreservesMessageMetadataAsync() + { + // Arrange — message has AuthorName and MessageId that should survive reduction + ToolResultReductionStrategy strategy = new( + new Dictionary> + { + ["fn"] = result => "reduced", + }, + trigger: _ => true); + + ChatMessage toolResultMessage = new(ChatRole.Tool, [new FunctionResultContent("c1", "original")]) + { + AuthorName = "tool-author", + MessageId = "msg-42", + }; + + CompactionMessageIndex index = CompactionMessageIndex.Create( + [ + new ChatMessage(ChatRole.User, "Q1"), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "fn")]), + toolResultMessage, + ]); + + // Act + await strategy.CompactAsync(index); + + // Assert — metadata preserved on the reduced message + List included = [.. index.GetIncludedMessages()]; + ChatMessage reduced = included[2]; + Assert.Equal("tool-author", reduced.AuthorName); + Assert.Equal("msg-42", reduced.MessageId); + + FunctionResultContent frc = reduced.Contents.OfType().Single(); + Assert.Equal("reduced", frc.Result?.ToString()); + } + + [Fact] + public async Task CompactAsyncDoesNotReReduceAlreadyReducedGroupsAsync() + { + // Arrange — calling CompactAsync twice should not re-reduce + int callCount = 0; + ToolResultReductionStrategy strategy = new( + new Dictionary> + { + ["fn"] = result => { callCount++; return $"reduced-{callCount}"; }, + }, + trigger: _ => true); + + CompactionMessageIndex index = CompactionMessageIndex.Create( + [ + new ChatMessage(ChatRole.User, "Q1"), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "fn")]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c1", "original")]), + ]); + + // Act — reduce twice + bool first = await strategy.CompactAsync(index); + bool second = await strategy.CompactAsync(index); + + // Assert — first call reduces, second is a no-op + Assert.True(first); + Assert.False(second); + Assert.Equal(1, callCount); + + List included = [.. index.GetIncludedMessages()]; + FunctionResultContent frc = included[2].Contents.OfType().Single(); + Assert.Equal("reduced-1", frc.Result?.ToString()); + } + + [Fact] + public async Task CompactAsyncSkipsGroupWithCallButNoMatchingResultAsync() + { + // Arrange — group has a registered FunctionCallContent but no FunctionResultContent + // (tool result is plain text, not FunctionResultContent) + ToolResultReductionStrategy strategy = new( + new Dictionary> + { + ["fn"] = result => "should not be called", + }, + trigger: _ => true); + + CompactionMessageIndex index = CompactionMessageIndex.Create( + [ + new ChatMessage(ChatRole.User, "Q1"), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "fn")]), + new ChatMessage(ChatRole.Tool, "Plain text result without FunctionResultContent"), + ]); + + // Act + bool result = await strategy.CompactAsync(index); + + // Assert — group is ineligible, no reduction + Assert.False(result); + } + + /// + /// Represents a retrieval API chunk with relevance score, matching the JSON structure + /// returned by a typical RAG tool. + /// + private sealed class ChunkResult + { + [JsonPropertyName("chunk")] + public string Chunk { get; set; } = string.Empty; + + [JsonPropertyName("relevance")] + public double Relevance { get; set; } + } + + [JsonSerializable(typeof(List))] + private sealed partial class ChunkSerializerContext : JsonSerializerContext; +}