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");