33using System ;
44using System . Collections . Generic ;
55using System . Diagnostics . CodeAnalysis ;
6- using System . Threading ;
7- using System . Threading . Tasks ;
86using Microsoft . Extensions . AI ;
9- using Microsoft . Extensions . Logging ;
107using Microsoft . Shared . DiagnosticIds ;
118
129namespace 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.
0 commit comments