diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/ChatReducerCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/ChatReducerCompactionStrategy.cs
index 3df6736527..af477fcb7b 100644
--- a/dotnet/src/Microsoft.Agents.AI/Compaction/ChatReducerCompactionStrategy.cs
+++ b/dotnet/src/Microsoft.Agents.AI/Compaction/ChatReducerCompactionStrategy.cs
@@ -69,13 +69,11 @@ public sealed class ChatReducerCompactionStrategy : CompactionStrategy
return false;
}
- // Rebuild the index from the reduced messages
- CompactionMessageIndex rebuilt = CompactionMessageIndex.Create(reducedMessages, index.Tokenizer);
- index.Groups.Clear();
- foreach (CompactionMessageGroup group in rebuilt.Groups)
- {
- index.Groups.Add(group);
- }
+ // Rebuild the index from the reduced messages.
+ // Use Update() rather than directly manipulating Groups so that
+ // cached metrics (IncludedGroupCount, token counts, etc.) are
+ // properly invalidated.
+ index.Update(reducedMessages);
return true;
}
diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionMessageGroup.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionMessageGroup.cs
index ddf4c20b24..a6f020d7a9 100644
--- a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionMessageGroup.cs
+++ b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionMessageGroup.cs
@@ -40,9 +40,15 @@ public sealed class CompactionMessageGroup
///
public static readonly string SummaryPropertyKey = "_is_summary";
+ private readonly Action _exclusionChangedCallback;
+
///
/// Initializes a new instance of the class.
///
+ ///
+ /// A callback invoked when changes value.
+ /// Used internally by to invalidate cached aggregates.
+ ///
/// The kind of message group.
/// The messages in this group. The list is captured as a read-only snapshot.
/// The total UTF-8 byte count of the text content in the messages.
@@ -51,8 +57,15 @@ public sealed class CompactionMessageGroup
/// The user turn this group belongs to, or for .
///
[JsonConstructor]
- internal CompactionMessageGroup(CompactionGroupKind kind, IReadOnlyList messages, int byteCount, int tokenCount, int? turnIndex = null)
+ internal CompactionMessageGroup(
+ Action exclusionChangedCallback,
+ CompactionGroupKind kind,
+ IReadOnlyList messages,
+ int byteCount,
+ int tokenCount,
+ int? turnIndex = null)
{
+ this._exclusionChangedCallback = exclusionChangedCallback;
this.Kind = kind;
this.Messages = messages;
this.MessageCount = messages.Count;
@@ -101,14 +114,6 @@ public sealed class CompactionMessageGroup
///
public int? TurnIndex { get; }
- private bool _isExcluded;
-
- ///
- /// An optional callback invoked when changes value.
- /// Used internally by to invalidate cached aggregates.
- ///
- internal Action? ExclusionChanged;
-
///
/// Gets or sets a value indicating whether this group is excluded from the projected message list.
///
@@ -118,13 +123,13 @@ public sealed class CompactionMessageGroup
///
public bool IsExcluded
{
- get => _isExcluded;
+ get;
set
{
- if (_isExcluded != value)
+ if (field != value)
{
- _isExcluded = value;
- ExclusionChanged?.Invoke();
+ field = value;
+ this._exclusionChangedCallback.Invoke();
}
}
}
diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionMessageIndex.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionMessageIndex.cs
index ca89277da5..8284b663fc 100644
--- a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionMessageIndex.cs
+++ b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionMessageIndex.cs
@@ -61,12 +61,6 @@ public sealed class CompactionMessageIndex
this.Groups = Throw.IfNull(groups, nameof(groups));
this.Tokenizer = tokenizer;
- // Register all pre-existing groups so that IsExcluded changes invalidate the cache.
- for (int i = 0; i < groups.Count; i++)
- {
- this.RegisterGroup(groups[i]);
- }
-
// Restore turn counter and last processed message from the groups
for (int index = groups.Count - 1; index >= 0; --index)
{
@@ -205,13 +199,13 @@ public sealed class CompactionMessageIndex
if (message.Role == ChatRole.System)
{
// System messages are not part of any turn
- this.AddAndRegisterGroup(CreateGroup(CompactionGroupKind.System, [message], this.Tokenizer, turnIndex: null));
+ this.AddGroup(CompactionGroupKind.System, [message], turnIndex: null);
index++;
}
else if (message.Role == ChatRole.User)
{
this._currentTurn++;
- this.AddAndRegisterGroup(CreateGroup(CompactionGroupKind.User, [message], this.Tokenizer, this._currentTurn));
+ this.AddGroup(CompactionGroupKind.User, [message], this._currentTurn);
index++;
}
else if (message.Role == ChatRole.Assistant && HasToolCalls(message))
@@ -228,11 +222,11 @@ public sealed class CompactionMessageIndex
index++;
}
- this.AddAndRegisterGroup(CreateGroup(CompactionGroupKind.ToolCall, groupMessages, this.Tokenizer, this._currentTurn));
+ this.AddGroup(CompactionGroupKind.ToolCall, groupMessages, this._currentTurn);
}
else if (message.Role == ChatRole.Assistant && IsSummaryMessage(message))
{
- this.AddAndRegisterGroup(CreateGroup(CompactionGroupKind.Summary, [message], this.Tokenizer, this._currentTurn));
+ this.AddGroup(CompactionGroupKind.Summary, [message], this._currentTurn);
index++;
}
else if (message.Role == ChatRole.Assistant && HasOnlyReasoning(message))
@@ -268,17 +262,17 @@ public sealed class CompactionMessageIndex
index++;
}
- this.AddAndRegisterGroup(CreateGroup(CompactionGroupKind.ToolCall, groupMessages, this.Tokenizer, this._currentTurn));
+ this.AddGroup(CompactionGroupKind.ToolCall, groupMessages, this._currentTurn);
}
else
{
- this.AddAndRegisterGroup(CreateGroup(CompactionGroupKind.AssistantText, [message], this.Tokenizer, this._currentTurn));
+ this.AddGroup(CompactionGroupKind.AssistantText, [message], this._currentTurn);
index++;
}
}
else
{
- this.AddAndRegisterGroup(CreateGroup(CompactionGroupKind.AssistantText, [message], this.Tokenizer, this._currentTurn));
+ this.AddGroup(CompactionGroupKind.AssistantText, [message], this._currentTurn);
index++;
}
}
@@ -302,9 +296,8 @@ public sealed class CompactionMessageIndex
/// The newly created .
public CompactionMessageGroup InsertGroup(int index, CompactionGroupKind kind, IReadOnlyList messages, int? turnIndex = null)
{
- CompactionMessageGroup group = CreateGroup(kind, messages, this.Tokenizer, turnIndex);
+ CompactionMessageGroup group = this.CreateGroup(kind, messages, this.Tokenizer, turnIndex); // %%% DERIVE TURNINDEX
this.Groups.Insert(index, group);
- this.RegisterGroup(group);
this.InvalidateCache();
return group;
}
@@ -319,9 +312,8 @@ public sealed class CompactionMessageIndex
/// The newly created .
public CompactionMessageGroup AddGroup(CompactionGroupKind kind, IReadOnlyList messages, int? turnIndex = null)
{
- CompactionMessageGroup group = CreateGroup(kind, messages, this.Tokenizer, turnIndex);
+ CompactionMessageGroup group = this.CreateGroup(kind, messages, this.Tokenizer, turnIndex); // %%% DERIVE TURNINDEX
this.Groups.Add(group);
- this.RegisterGroup(group);
this.InvalidateCache();
return group;
}
@@ -347,57 +339,57 @@ public sealed class CompactionMessageIndex
///
/// Gets the total number of messages across all groups, including excluded ones.
///
- public int TotalMessageCount => _cachedTotalMessageCount ??= this.Groups.Sum(group => group.MessageCount);
+ public int TotalMessageCount => this._cachedTotalMessageCount ??= this.Groups.Sum(group => group.MessageCount);
///
/// Gets the total UTF-8 byte count across all groups, including excluded ones.
///
- public int TotalByteCount => _cachedTotalByteCount ??= this.Groups.Sum(group => group.ByteCount);
+ public int TotalByteCount => this._cachedTotalByteCount ??= this.Groups.Sum(group => group.ByteCount);
///
/// Gets the total token count across all groups, including excluded ones.
///
- public int TotalTokenCount => _cachedTotalTokenCount ??= this.Groups.Sum(group => group.TokenCount);
+ public int TotalTokenCount => this._cachedTotalTokenCount ??= this.Groups.Sum(group => group.TokenCount);
///
/// Gets the total number of groups that are not excluded.
///
- public int IncludedGroupCount => _cachedIncludedGroupCount ??= this.Groups.Count(group => !group.IsExcluded);
+ public int IncludedGroupCount => this._cachedIncludedGroupCount ??= this.Groups.Count(group => !group.IsExcluded);
///
/// Gets the total number of messages across all included (non-excluded) groups.
///
- public int IncludedMessageCount => _cachedIncludedMessageCount ??= this.Groups.Where(group => !group.IsExcluded).Sum(group => group.MessageCount);
+ public int IncludedMessageCount => this._cachedIncludedMessageCount ??= this.Groups.Where(group => !group.IsExcluded).Sum(group => group.MessageCount);
///
/// Gets the total UTF-8 byte count across all included (non-excluded) groups.
///
- public int IncludedByteCount => _cachedIncludedByteCount ??= this.Groups.Where(group => !group.IsExcluded).Sum(group => group.ByteCount);
+ public int IncludedByteCount => this._cachedIncludedByteCount ??= this.Groups.Where(group => !group.IsExcluded).Sum(group => group.ByteCount);
///
/// Gets the total token count across all included (non-excluded) groups.
///
- public int IncludedTokenCount => _cachedIncludedTokenCount ??= this.Groups.Where(group => !group.IsExcluded).Sum(group => group.TokenCount);
+ public int IncludedTokenCount => this._cachedIncludedTokenCount ??= this.Groups.Where(group => !group.IsExcluded).Sum(group => group.TokenCount);
///
/// Gets the total number of user turns across all groups (including those with excluded groups).
///
- public int TotalTurnCount => _cachedTotalTurnCount ??= this.Groups.Select(group => group.TurnIndex).Distinct().Count(turnIndex => turnIndex is not null && turnIndex > 0);
+ public int TotalTurnCount => this._cachedTotalTurnCount ??= this.Groups.Select(group => group.TurnIndex).Distinct().Count(turnIndex => turnIndex is not null && turnIndex > 0);
///
/// Gets the number of user turns that have at least one non-excluded group.
///
- public int IncludedTurnCount => _cachedIncludedTurnCount ??= this.Groups.Where(group => !group.IsExcluded && group.TurnIndex is not null && group.TurnIndex > 0).Select(group => group.TurnIndex).Distinct().Count();
+ public int IncludedTurnCount => this._cachedIncludedTurnCount ??= this.Groups.Where(group => !group.IsExcluded && group.TurnIndex is not null && group.TurnIndex > 0).Select(group => group.TurnIndex).Distinct().Count();
///
/// Gets the total number of groups across all included (non-excluded) groups that are not .
///
- public int IncludedNonSystemGroupCount => _cachedIncludedNonSystemGroupCount ??= this.Groups.Count(group => !group.IsExcluded && group.Kind != CompactionGroupKind.System);
+ public int IncludedNonSystemGroupCount => this._cachedIncludedNonSystemGroupCount ??= this.Groups.Count(group => !group.IsExcluded && group.Kind != CompactionGroupKind.System);
///
/// Gets the total number of original messages (that are not summaries).
///
- public int RawMessageCount => _cachedRawMessageCount ??= this.Groups.Where(group => group.Kind != CompactionGroupKind.Summary).Sum(group => group.MessageCount);
+ public int RawMessageCount => this._cachedRawMessageCount ??= this.Groups.Where(group => group.Kind != CompactionGroupKind.Summary).Sum(group => group.MessageCount);
///
/// Returns all groups that belong to the specified user turn.
@@ -408,33 +400,17 @@ public sealed class CompactionMessageIndex
private void InvalidateCache()
{
- _cachedTotalMessageCount = null;
- _cachedTotalByteCount = null;
- _cachedTotalTokenCount = null;
- _cachedIncludedGroupCount = null;
- _cachedIncludedMessageCount = null;
- _cachedIncludedByteCount = null;
- _cachedIncludedTokenCount = null;
- _cachedTotalTurnCount = null;
- _cachedIncludedTurnCount = null;
- _cachedIncludedNonSystemGroupCount = null;
- _cachedRawMessageCount = null;
- }
-
- private void RegisterGroup(CompactionMessageGroup group)
- {
- // Each group is owned by exactly one index, so assignment rather than
- // += is intentional — no need to chain callbacks.
- group.ExclusionChanged = this.InvalidateCache;
- }
-
- // Adds the group to the list and registers it for cache invalidation.
- // Callers that add many groups in a loop (e.g. AppendFromMessages) call
- // InvalidateCache() once at the end rather than per-group for efficiency.
- private void AddAndRegisterGroup(CompactionMessageGroup group)
- {
- this.Groups.Add(group);
- this.RegisterGroup(group);
+ this._cachedTotalMessageCount = null;
+ this._cachedTotalByteCount = null;
+ this._cachedTotalTokenCount = null;
+ this._cachedIncludedGroupCount = null;
+ this._cachedIncludedMessageCount = null;
+ this._cachedIncludedByteCount = null;
+ this._cachedIncludedTokenCount = null;
+ this._cachedTotalTurnCount = null;
+ this._cachedIncludedTurnCount = null;
+ this._cachedIncludedNonSystemGroupCount = null;
+ this._cachedRawMessageCount = null;
}
///
@@ -555,14 +531,14 @@ public sealed class CompactionMessageIndex
private static int GetStringByteCount(string? value) =>
value is { Length: > 0 } ? Encoding.UTF8.GetByteCount(value) : 0;
- private static CompactionMessageGroup CreateGroup(CompactionGroupKind kind, IReadOnlyList messages, Tokenizer? tokenizer, int? turnIndex)
+ private CompactionMessageGroup CreateGroup(CompactionGroupKind kind, IReadOnlyList messages, Tokenizer? tokenizer, int? turnIndex)
{
int byteCount = ComputeByteCount(messages);
int tokenCount = tokenizer is not null
? ComputeTokenCount(messages, tokenizer)
: byteCount / 4;
- return new CompactionMessageGroup(kind, messages, byteCount, tokenCount, turnIndex);
+ return new CompactionMessageGroup(this.InvalidateCache, kind, messages, byteCount, tokenCount, turnIndex);
}
private static bool HasToolCalls(ChatMessage message)
diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionMessageIndexTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionMessageIndexTests.cs
index f8b4ebdc5b..ffe493fb47 100644
--- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionMessageIndexTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionMessageIndexTests.cs
@@ -263,7 +263,7 @@ public class CompactionMessageIndexTests
public void MessageGroupStoresPassedCounts()
{
// Arrange & Act
- CompactionMessageGroup group = new(CompactionGroupKind.User, [new ChatMessage(ChatRole.User, "Hello")], byteCount: 5, tokenCount: 2);
+ CompactionMessageGroup group = new(InvalidateCallback, CompactionGroupKind.User, [new ChatMessage(ChatRole.User, "Hello")], byteCount: 5, tokenCount: 2);
// Assert
Assert.Equal(1, group.MessageCount);
@@ -276,7 +276,7 @@ public class CompactionMessageIndexTests
{
// Arrange
IReadOnlyList messages = [new ChatMessage(ChatRole.User, "Hello")];
- CompactionMessageGroup group = new(CompactionGroupKind.User, messages, byteCount: 5, tokenCount: 1);
+ CompactionMessageGroup group = new(InvalidateCallback, CompactionGroupKind.User, messages, byteCount: 5, tokenCount: 1);
// Assert — Messages is IReadOnlyList, not IList
Assert.IsType>(group.Messages, exactMatch: false);
@@ -752,9 +752,9 @@ public class CompactionMessageIndexTests
public void ConstructorWithGroupsRestoresTurnIndex()
{
// Arrange — pre-existing groups with turn indices
- CompactionMessageGroup group1 = new(CompactionGroupKind.User, [new ChatMessage(ChatRole.User, "Q1")], 2, 1, turnIndex: 1);
- CompactionMessageGroup group2 = new(CompactionGroupKind.AssistantText, [new ChatMessage(ChatRole.Assistant, "A1")], 2, 1, turnIndex: 1);
- CompactionMessageGroup group3 = new(CompactionGroupKind.User, [new ChatMessage(ChatRole.User, "Q2")], 2, 1, turnIndex: 2);
+ CompactionMessageGroup group1 = new(InvalidateCallback, CompactionGroupKind.User, [new ChatMessage(ChatRole.User, "Q1")], 2, 1, turnIndex: 1);
+ CompactionMessageGroup group2 = new(InvalidateCallback, CompactionGroupKind.AssistantText, [new ChatMessage(ChatRole.Assistant, "A1")], 2, 1, turnIndex: 1);
+ CompactionMessageGroup group3 = new(InvalidateCallback, CompactionGroupKind.User, [new ChatMessage(ChatRole.User, "Q2")], 2, 1, turnIndex: 2);
List groups = [group1, group2, group3];
// Act — constructor should restore _currentTurn from the last group's TurnIndex
@@ -789,7 +789,7 @@ public class CompactionMessageIndexTests
public void ConstructorWithGroupsWithoutTurnIndexSkipsRestore()
{
// Arrange — groups without turn indices (system messages)
- CompactionMessageGroup systemGroup = new(CompactionGroupKind.System, [new ChatMessage(ChatRole.System, "Be helpful")], 10, 3, turnIndex: null);
+ CompactionMessageGroup systemGroup = new(InvalidateCallback, CompactionGroupKind.System, [new ChatMessage(ChatRole.System, "Be helpful")], 10, 3, turnIndex: null);
List groups = [systemGroup];
// Act — constructor won't find a TurnIndex to restore
@@ -1648,23 +1648,5 @@ public class CompactionMessageIndexTests
Assert.Equal(2, index.TotalTokenCount);
}
- [Fact]
- public void ConstructorWithPreExistingGroupsRegistersThemForCacheInvalidation()
- {
- // Arrange — build groups externally, pass them into constructor
- CompactionMessageGroup group1 = new(CompactionGroupKind.User, [new ChatMessage(ChatRole.User, "Q1")], 2, 1, turnIndex: 1);
- CompactionMessageGroup group2 = new(CompactionGroupKind.AssistantText, [new ChatMessage(ChatRole.Assistant, "A1")], 2, 1, turnIndex: 1);
- List groups = [group1, group2];
-
- CompactionMessageIndex index = new(groups);
-
- // Prime the cache
- Assert.Equal(2, index.IncludedGroupCount);
-
- // Act — exclude a pre-existing group (ExclusionChanged should fire and invalidate)
- group1.IsExcluded = true;
-
- // Assert — cache reflects the change
- Assert.Equal(1, index.IncludedGroupCount);
- }
+ private static void InvalidateCallback() { }
}
diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionProviderTests.cs
index 317f7d86ed..a164b68838 100644
--- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionProviderTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionProviderTests.cs
@@ -356,7 +356,7 @@ public sealed class CompactionProviderTests
Assert.Empty(state.MessageGroups);
// Act
- state.MessageGroups = [new CompactionMessageGroup(CompactionGroupKind.User, [], 0, 0, 0)];
+ state.MessageGroups = [new CompactionMessageGroup(() => { }, CompactionGroupKind.User, [], 0, 0, 0)];
// Assert
Assert.Single(state.MessageGroups);
diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/MessageMergerTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/MessageMergerTests.cs
index 704e25b14a..4181dad409 100644
--- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/MessageMergerTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/MessageMergerTests.cs
@@ -17,7 +17,7 @@ public class MessageMergerTests
[Fact]
public void Test_MessageMerger_AssemblesMessage()
{
- DateTimeOffset creationTime = DateTimeOffset.UtcNow;
+ DateTimeOffset creationTime = DateTimeOffset.UtcNow.Subtract(TimeSpan.FromSeconds(1));
string responseId = Guid.NewGuid().ToString("N");
string messageId = Guid.NewGuid().ToString("N");