Skip to content

Commit f293963

Browse files
committed
.NET: Introduce ToolResultReductionStrategy
1 parent 7e6d87e commit f293963

4 files changed

Lines changed: 921 additions & 110 deletions

File tree

dotnet/src/Microsoft.Agents.AI/Compaction/ToolResultCompactionStrategy.cs

Lines changed: 12 additions & 110 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,7 @@
33
using System;
44
using System.Collections.Generic;
55
using System.Diagnostics.CodeAnalysis;
6-
using System.Threading;
7-
using System.Threading.Tasks;
86
using Microsoft.Extensions.AI;
9-
using Microsoft.Extensions.Logging;
107
using Microsoft.Shared.DiagnosticIds;
118

129
namespace Microsoft.Agents.AI.Compaction;
@@ -37,16 +34,17 @@ namespace Microsoft.Agents.AI.Compaction;
3734
/// built-in default and can be reused inside a custom formatter when needed.
3835
/// </para>
3936
/// <para>
40-
/// <see cref="MinimumPreservedGroups"/> is a hard floor: even if the <see cref="CompactionStrategy.Target"/>
41-
/// has not been reached, compaction will not touch the last <see cref="MinimumPreservedGroups"/> non-system groups.
37+
/// <see cref="ToolResultStrategyBase.MinimumPreservedGroups"/> is a hard floor: even if the
38+
/// <see cref="CompactionStrategy.Target"/> has not been reached, compaction will not touch the last
39+
/// <see cref="ToolResultStrategyBase.MinimumPreservedGroups"/> non-system groups.
4240
/// </para>
4341
/// <para>
4442
/// The <see cref="CompactionTrigger"/> predicate controls when compaction proceeds. Use
4543
/// <see cref="CompactionTriggers"/> for common trigger conditions such as token thresholds.
4644
/// </para>
4745
/// </remarks>
4846
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
49-
public sealed class ToolResultCompactionStrategy : CompactionStrategy
47+
public sealed class ToolResultCompactionStrategy : ToolResultStrategyBase
5048
{
5149
/// <summary>
5250
/// The default minimum number of most-recent non-system groups to preserve.
@@ -73,17 +71,10 @@ public ToolResultCompactionStrategy(
7371
CompactionTrigger trigger,
7472
int minimumPreservedGroups = DefaultMinimumPreserved,
7573
CompactionTrigger? target = null)
76-
: base(trigger, target)
74+
: base(trigger, minimumPreservedGroups, target)
7775
{
78-
this.MinimumPreservedGroups = EnsureNonNegative(minimumPreservedGroups);
7976
}
8077

81-
/// <summary>
82-
/// Gets the minimum number of most-recent non-system groups that are always preserved.
83-
/// This is a hard floor that compaction cannot exceed, regardless of the target condition.
84-
/// </summary>
85-
public int MinimumPreservedGroups { get; }
86-
8778
/// <summary>
8879
/// An optional custom formatter that converts a <see cref="CompactionMessageGroup"/> into a summary string.
8980
/// When <see langword="null"/>, <see cref="DefaultToolCallFormatter"/> is used, which produces a YAML-like
@@ -92,73 +83,15 @@ public ToolResultCompactionStrategy(
9283
public Func<CompactionMessageGroup, string>? ToolCallFormatter { get; init; }
9384

9485
/// <inheritdoc/>
95-
protected override ValueTask<bool> CompactCoreAsync(CompactionMessageIndex index, ILogger logger, CancellationToken cancellationToken)
86+
protected override (CompactionGroupKind Kind, List<ChatMessage> Messages, string ExcludeReason)
87+
TransformToolGroup(CompactionMessageGroup group)
9688
{
97-
// Identify protected groups: the N most-recent non-system, non-excluded groups
98-
List<int> nonSystemIncludedIndices = [];
99-
for (int i = 0; i < index.Groups.Count; i++)
100-
{
101-
CompactionMessageGroup group = index.Groups[i];
102-
if (!group.IsExcluded && group.Kind != CompactionGroupKind.System)
103-
{
104-
nonSystemIncludedIndices.Add(i);
105-
}
106-
}
107-
108-
int protectedStart = EnsureNonNegative(nonSystemIncludedIndices.Count - this.MinimumPreservedGroups);
109-
HashSet<int> protectedGroupIndices = [];
110-
for (int i = protectedStart; i < nonSystemIncludedIndices.Count; i++)
111-
{
112-
protectedGroupIndices.Add(nonSystemIncludedIndices[i]);
113-
}
114-
115-
// Collect eligible tool groups in order (oldest first)
116-
List<int> eligibleIndices = [];
117-
for (int i = 0; i < index.Groups.Count; i++)
118-
{
119-
CompactionMessageGroup group = index.Groups[i];
120-
if (!group.IsExcluded && group.Kind == CompactionGroupKind.ToolCall && !protectedGroupIndices.Contains(i))
121-
{
122-
eligibleIndices.Add(i);
123-
}
124-
}
125-
126-
if (eligibleIndices.Count == 0)
127-
{
128-
return new ValueTask<bool>(false);
129-
}
130-
131-
// Collapse one tool group at a time from oldest, re-checking target after each
132-
bool compacted = false;
133-
int offset = 0;
134-
135-
for (int e = 0; e < eligibleIndices.Count; e++)
136-
{
137-
int idx = eligibleIndices[e] + offset;
138-
CompactionMessageGroup group = index.Groups[idx];
139-
140-
string summary = (this.ToolCallFormatter ?? DefaultToolCallFormatter).Invoke(group);
89+
string summary = (this.ToolCallFormatter ?? DefaultToolCallFormatter).Invoke(group);
14190

142-
// Exclude the original group and insert a collapsed replacement
143-
group.IsExcluded = true;
144-
group.ExcludeReason = $"Collapsed by {nameof(ToolResultCompactionStrategy)}";
91+
ChatMessage summaryMessage = new(ChatRole.Assistant, summary);
92+
(summaryMessage.AdditionalProperties ??= [])[CompactionMessageGroup.SummaryPropertyKey] = true;
14593

146-
ChatMessage summaryMessage = new(ChatRole.Assistant, summary);
147-
(summaryMessage.AdditionalProperties ??= [])[CompactionMessageGroup.SummaryPropertyKey] = true;
148-
149-
index.InsertGroup(idx + 1, CompactionGroupKind.Summary, [summaryMessage], group.TurnIndex);
150-
offset++; // Each insertion shifts subsequent indices by 1
151-
152-
compacted = true;
153-
154-
// Stop when target condition is met
155-
if (this.Target(index))
156-
{
157-
break;
158-
}
159-
}
160-
161-
return new ValueTask<bool>(compacted);
94+
return (CompactionGroupKind.Summary, [summaryMessage], $"Collapsed by {nameof(ToolResultCompactionStrategy)}");
16295
}
16396

16497
/// <summary>
@@ -171,38 +104,7 @@ protected override ValueTask<bool> CompactCoreAsync(CompactionMessageIndex index
171104
/// </remarks>
172105
public static string DefaultToolCallFormatter(CompactionMessageGroup group)
173106
{
174-
// Collect function calls (callId, name) and results (callId → result text)
175-
List<(string CallId, string Name)> functionCalls = [];
176-
Dictionary<string, string> resultsByCallId = [];
177-
List<string> plainTextResults = [];
178-
179-
foreach (ChatMessage message in group.Messages)
180-
{
181-
if (message.Contents is null)
182-
{
183-
continue;
184-
}
185-
186-
bool hasFunctionResult = false;
187-
foreach (AIContent content in message.Contents)
188-
{
189-
if (content is FunctionCallContent fcc)
190-
{
191-
functionCalls.Add((fcc.CallId, fcc.Name));
192-
}
193-
else if (content is FunctionResultContent frc && frc.CallId is not null)
194-
{
195-
resultsByCallId[frc.CallId] = frc.Result?.ToString() ?? string.Empty;
196-
hasFunctionResult = true;
197-
}
198-
}
199-
200-
// Collect plain text from Tool-role messages that lack FunctionResultContent
201-
if (!hasFunctionResult && message.Role == ChatRole.Tool && message.Text is string text)
202-
{
203-
plainTextResults.Add(text);
204-
}
205-
}
107+
var (functionCalls, resultsByCallId, plainTextResults) = ExtractToolCallsAndResults(group);
206108

207109
// Match function calls to their results using CallId or positional fallback,
208110
// grouping by tool name while preserving first-seen order.
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
// Copyright (c) Microsoft. All rights reserved.
2+
3+
using System;
4+
using System.Collections.Generic;
5+
using System.Diagnostics.CodeAnalysis;
6+
using Microsoft.Extensions.AI;
7+
using Microsoft.Shared.DiagnosticIds;
8+
using Microsoft.Shared.Diagnostics;
9+
10+
namespace Microsoft.Agents.AI.Compaction;
11+
12+
/// <summary>
13+
/// A compaction strategy that applies per-tool reducers to individual tool results,
14+
/// preserving the original message structure (assistant tool calls + tool result messages)
15+
/// while transforming the result content.
16+
/// </summary>
17+
/// <remarks>
18+
/// <para>
19+
/// Unlike <see cref="ToolResultCompactionStrategy"/> which collapses entire tool call groups
20+
/// into a single YAML-like summary, this strategy keeps the tool call/result message pairing
21+
/// intact. Each <see cref="FunctionResultContent"/> is passed through its tool's registered
22+
/// reducer, and the group is replaced with a structurally identical group containing the
23+
/// reduced results.
24+
/// </para>
25+
/// <para>
26+
/// This is useful when a tool returns very large results (e.g., a retrieval API returning
27+
/// hundreds of thousands of tokens with relevance scores) that should be reduced before
28+
/// the model sees them — even on the current turn. The reducer can deserialize the result,
29+
/// filter, sort, and re-serialize it. These steps happen outside the framework; the framework
30+
/// only invokes the <c>Func&lt;string, string&gt;</c> delegate.
31+
/// </para>
32+
/// <para>
33+
/// Only <see cref="CompactionGroupKind.ToolCall"/> groups that contain at least one tool name
34+
/// registered in <see cref="ToolResultReducers"/> are eligible. Groups with no registered tools
35+
/// are left untouched for other strategies to handle.
36+
/// </para>
37+
/// <para>
38+
/// For tools not registered in the dictionary within an otherwise eligible group, the raw
39+
/// result text is preserved as-is.
40+
/// </para>
41+
/// <para>
42+
/// <see cref="ToolResultStrategyBase.MinimumPreservedGroups"/> defaults to <c>0</c> so that
43+
/// reducers apply to all tool results, including the current turn. Set a higher value to
44+
/// preserve recent results at full fidelity.
45+
/// </para>
46+
/// <para>
47+
/// This strategy composes naturally in a <see cref="PipelineCompactionStrategy"/>. A common
48+
/// pattern is to place it before <see cref="ToolResultCompactionStrategy"/> — this strategy
49+
/// reduces result content while preserving structure, then the compaction strategy collapses
50+
/// older groups into concise summaries.
51+
/// </para>
52+
/// </remarks>
53+
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
54+
public sealed class ToolResultReductionStrategy : ToolResultStrategyBase
55+
{
56+
/// <summary>
57+
/// Initializes a new instance of the <see cref="ToolResultReductionStrategy"/> class.
58+
/// </summary>
59+
/// <param name="toolResultReducers">
60+
/// A dictionary mapping tool names to per-tool result reducers. Each reducer receives the
61+
/// raw result text for a single tool invocation and returns the transformed text.
62+
/// </param>
63+
/// <param name="trigger">
64+
/// The <see cref="CompactionTrigger"/> that controls when reduction proceeds.
65+
/// </param>
66+
/// <param name="minimumPreservedGroups">
67+
/// The minimum number of most-recent non-system message groups to preserve.
68+
/// Defaults to <c>0</c> so that reducers apply to all tool results including the current turn.
69+
/// </param>
70+
/// <param name="target">
71+
/// An optional target condition that controls when reduction stops. When <see langword="null"/>,
72+
/// defaults to the inverse of the <paramref name="trigger"/>.
73+
/// </param>
74+
public ToolResultReductionStrategy(
75+
IReadOnlyDictionary<string, Func<string, string>> toolResultReducers,
76+
CompactionTrigger trigger,
77+
int minimumPreservedGroups = 0,
78+
CompactionTrigger? target = null)
79+
: base(trigger, minimumPreservedGroups, target)
80+
{
81+
this.ToolResultReducers = Throw.IfNull(toolResultReducers);
82+
}
83+
84+
/// <summary>
85+
/// Gets the dictionary mapping tool names to per-tool result reducers.
86+
/// Each reducer receives the raw result text for a single tool invocation and returns
87+
/// the transformed text.
88+
/// </summary>
89+
public IReadOnlyDictionary<string, Func<string, string>> ToolResultReducers { get; }
90+
91+
/// <inheritdoc/>
92+
protected override bool IsToolGroupEligible(CompactionMessageGroup group)
93+
{
94+
if (this.ToolResultReducers.Count == 0)
95+
{
96+
return false;
97+
}
98+
99+
foreach (ChatMessage message in group.Messages)
100+
{
101+
if (message.Contents is null)
102+
{
103+
continue;
104+
}
105+
106+
foreach (AIContent content in message.Contents)
107+
{
108+
if (content is FunctionCallContent fcc && this.ToolResultReducers.ContainsKey(fcc.Name))
109+
{
110+
return true;
111+
}
112+
}
113+
}
114+
115+
return false;
116+
}
117+
118+
/// <inheritdoc/>
119+
protected override (CompactionGroupKind Kind, List<ChatMessage> Messages, string ExcludeReason)
120+
TransformToolGroup(CompactionMessageGroup group)
121+
{
122+
// Build a CallId → tool name mapping from the shared extraction helper
123+
var (functionCalls, _, _) = ExtractToolCallsAndResults(group);
124+
125+
Dictionary<string, string> callIdToName = [];
126+
foreach ((string callId, string name) in functionCalls)
127+
{
128+
callIdToName[callId] = name;
129+
}
130+
131+
// Rebuild messages with reduced FunctionResultContent
132+
List<ChatMessage> reducedMessages = [];
133+
foreach (ChatMessage message in group.Messages)
134+
{
135+
if (message.Contents is null || message.Contents.Count == 0)
136+
{
137+
reducedMessages.Add(message);
138+
continue;
139+
}
140+
141+
bool hasReduction = false;
142+
List<AIContent> newContents = [];
143+
144+
foreach (AIContent content in message.Contents)
145+
{
146+
if (content is FunctionResultContent frc
147+
&& frc.CallId is not null
148+
&& callIdToName.TryGetValue(frc.CallId, out string? toolName)
149+
&& this.ToolResultReducers.TryGetValue(toolName, out Func<string, string>? reducer))
150+
{
151+
string original = frc.Result?.ToString() ?? string.Empty;
152+
string reduced = reducer(original);
153+
newContents.Add(new FunctionResultContent(frc.CallId, reduced));
154+
hasReduction = true;
155+
}
156+
else
157+
{
158+
newContents.Add(content);
159+
}
160+
}
161+
162+
if (hasReduction)
163+
{
164+
reducedMessages.Add(new ChatMessage(message.Role, newContents));
165+
}
166+
else
167+
{
168+
reducedMessages.Add(message);
169+
}
170+
}
171+
172+
return (CompactionGroupKind.ToolCall, reducedMessages, $"Reduced by {nameof(ToolResultReductionStrategy)}");
173+
}
174+
}

0 commit comments

Comments
 (0)