From 3ca44a66f2650e20bb4892e5395394b6226eee0f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Feb 2026 09:19:23 +0000 Subject: [PATCH 1/5] Initial plan From d41982186769c9a98985bee857c3d5fb27983145 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Feb 2026 09:25:44 +0000 Subject: [PATCH 2/5] Add ChatReducerTriggerEvent enum and trigger-aware reducer support Co-authored-by: markwallace-microsoft <127216156+markwallace-microsoft@users.noreply.github.com> --- dotnet/src/Agents/Core/ChatHistoryAgent.cs | 24 ++- dotnet/src/Agents/Core/ChatHistoryChannel.cs | 20 ++- .../AfterToolCallResponseReceivedTests.cs | 146 ++++++++++++++++++ .../Core/ChatReducerTriggerEventTests.cs | 123 +++++++++++++++ .../ChatCompletion/ChatHistoryReducerBase.cs | 37 +++++ .../ChatCompletion/ChatReducerTriggerEvent.cs | 37 +++++ .../IChatHistoryReducerWithTrigger.cs | 33 ++++ 7 files changed, 415 insertions(+), 5 deletions(-) create mode 100644 dotnet/src/Agents/UnitTests/Core/AfterToolCallResponseReceivedTests.cs create mode 100644 dotnet/src/Agents/UnitTests/Core/ChatReducerTriggerEventTests.cs create mode 100644 dotnet/src/SemanticKernel.Abstractions/AI/ChatCompletion/ChatHistoryReducerBase.cs create mode 100644 dotnet/src/SemanticKernel.Abstractions/AI/ChatCompletion/ChatReducerTriggerEvent.cs create mode 100644 dotnet/src/SemanticKernel.Abstractions/AI/ChatCompletion/IChatHistoryReducerWithTrigger.cs diff --git a/dotnet/src/Agents/Core/ChatHistoryAgent.cs b/dotnet/src/Agents/Core/ChatHistoryAgent.cs index e3e8f3a2410c..92b9fb942184 100644 --- a/dotnet/src/Agents/Core/ChatHistoryAgent.cs +++ b/dotnet/src/Agents/Core/ChatHistoryAgent.cs @@ -25,7 +25,7 @@ public abstract class ChatHistoryAgent : Agent /// /// /// The reducer is automatically applied to the history before invoking the agent, only when using - /// an . It must be explicitly applied via . + /// an . It must be explicitly applied via . /// [Experimental("SKEXP0110")] public IChatHistoryReducer? HistoryReducer { get; init; } @@ -68,6 +68,28 @@ protected internal abstract IAsyncEnumerable Invoke public Task ReduceAsync(ChatHistory history, CancellationToken cancellationToken = default) => history.ReduceInPlaceAsync(this.HistoryReducer, cancellationToken); + /// + /// Reduces the provided history for a specific trigger event. + /// + /// The source history. + /// The trigger event that is invoking the reduction. + /// The to monitor for cancellation requests. The default is . + /// if reduction occurred. + [Experimental("SKEXP0110")] + public Task ReduceAsync(ChatHistory history, ChatReducerTriggerEvent triggerEvent, CancellationToken cancellationToken = default) + { + // If the reducer supports triggers, only reduce if it's configured for this trigger + if (this.HistoryReducer is IChatHistoryReducerWithTrigger triggerReducer) + { + if (!triggerReducer.ShouldTriggerOn(triggerEvent)) + { + return Task.FromResult(false); + } + } + + return history.ReduceInPlaceAsync(this.HistoryReducer, cancellationToken); + } + /// [Experimental("SKEXP0110")] protected sealed override IEnumerable GetChannelKeys() diff --git a/dotnet/src/Agents/Core/ChatHistoryChannel.cs b/dotnet/src/Agents/Core/ChatHistoryChannel.cs index 4139858e7ff9..2927a2a31939 100644 --- a/dotnet/src/Agents/Core/ChatHistoryChannel.cs +++ b/dotnet/src/Agents/Core/ChatHistoryChannel.cs @@ -40,8 +40,8 @@ internal sealed class ChatHistoryChannel : AgentChannel throw new KernelException($"Invalid channel binding for agent: {agent.Id} ({agent.GetType().FullName})"); } - // Pre-process history reduction. - await historyAgent.ReduceAsync(this._history, cancellationToken).ConfigureAwait(false); + // Pre-process history reduction with BeforeMessagesRetrieval trigger. + await historyAgent.ReduceAsync(this._history, ChatReducerTriggerEvent.BeforeMessagesRetrieval, cancellationToken).ConfigureAwait(false); // Capture the current message count to evaluate history mutation. int messageCount = this._history.Count; @@ -71,6 +71,12 @@ internal sealed class ChatHistoryChannel : AgentChannel messageQueue.Enqueue(responseMessage); } + // Check if this message contains a function result and trigger reduction if configured + if (responseMessage.Items.Any(i => i is FunctionResultContent)) + { + await historyAgent.ReduceAsync(this._history, ChatReducerTriggerEvent.AfterToolCallResponseReceived, cancellationToken).ConfigureAwait(false); + } + // Dequeue the next message to yield. yieldMessage = messageQueue.Dequeue(); yield return (IsMessageVisible(yieldMessage), yieldMessage); @@ -98,8 +104,8 @@ protected override async IAsyncEnumerable InvokeStr throw new KernelException($"Invalid channel binding for agent: {agent.Id} ({agent.GetType().FullName})"); } - // Pre-process history reduction. - await historyAgent.ReduceAsync(this._history, cancellationToken).ConfigureAwait(false); + // Pre-process history reduction with BeforeMessagesRetrieval trigger. + await historyAgent.ReduceAsync(this._history, ChatReducerTriggerEvent.BeforeMessagesRetrieval, cancellationToken).ConfigureAwait(false); int messageCount = this._history.Count; @@ -111,6 +117,12 @@ protected override async IAsyncEnumerable InvokeStr for (int index = messageCount; index < this._history.Count; ++index) { messages.Add(this._history[index]); + + // Check if this message contains a function result and trigger reduction if configured + if (this._history[index].Items.Any(i => i is FunctionResultContent)) + { + await historyAgent.ReduceAsync(this._history, ChatReducerTriggerEvent.AfterToolCallResponseReceived, cancellationToken).ConfigureAwait(false); + } } } diff --git a/dotnet/src/Agents/UnitTests/Core/AfterToolCallResponseReceivedTests.cs b/dotnet/src/Agents/UnitTests/Core/AfterToolCallResponseReceivedTests.cs new file mode 100644 index 000000000000..6b55ab2cb75f --- /dev/null +++ b/dotnet/src/Agents/UnitTests/Core/AfterToolCallResponseReceivedTests.cs @@ -0,0 +1,146 @@ +// Copyright (c) Microsoft. All rights reserved. +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.Agents; +using Microsoft.SemanticKernel.ChatCompletion; +using Xunit; + +namespace SemanticKernel.Agents.UnitTests.Core; + +/// +/// Integration tests for . +/// +public class AfterToolCallResponseReceivedTests +{ + /// + /// Verify that the AfterToolCallResponseReceived trigger fires when function results are received. + /// + [Fact] + public async Task VerifyAfterToolCallResponseReceivedTriggerFiresAsync() + { + // Arrange + var reducer = new CountingReducer(ChatReducerTriggerEvent.AfterToolCallResponseReceived); + var agent = new TestChatHistoryAgent(reducer); + var history = new ChatHistory(); + + history.AddUserMessage("User message 1"); + history.AddAssistantMessage("Assistant response 1"); + + // Add a function call and result + history.Add(new ChatMessageContent(AuthorRole.Assistant, [new FunctionCallContent("test-func")])); + history.Add(new ChatMessageContent(AuthorRole.Tool, [new FunctionResultContent("test-func", "result")])); + + // Act - trigger reduction with the AfterToolCallResponseReceived event + await agent.ReduceAsync(history, ChatReducerTriggerEvent.AfterToolCallResponseReceived); + + // Assert - the reducer should have been invoked + Assert.Equal(1, reducer.InvocationCount); + } + + /// + /// Verify that the AfterToolCallResponseReceived trigger does not fire for non-tool messages. + /// + [Fact] + public async Task VerifyAfterToolCallResponseReceivedTriggerDoesNotFireForNonToolMessagesAsync() + { + // Arrange + var reducer = new CountingReducer(ChatReducerTriggerEvent.AfterToolCallResponseReceived); + var agent = new TestChatHistoryAgent(reducer); + var history = new ChatHistory(); + + history.AddUserMessage("User message 1"); + history.AddAssistantMessage("Assistant response 1"); + + // Act - trigger reduction with BeforeMessagesRetrieval (not the configured trigger) + await agent.ReduceAsync(history, ChatReducerTriggerEvent.BeforeMessagesRetrieval); + + // Assert - the reducer should NOT have been invoked because it's only configured for AfterToolCallResponseReceived + Assert.Equal(0, reducer.InvocationCount); + } + + /// + /// Verify that the reducer is invoked multiple times for multiple tool call responses. + /// + [Fact] + public async Task VerifyMultipleToolCallResponsesInvokeReducerMultipleTimesAsync() + { + // Arrange + var reducer = new CountingReducer(ChatReducerTriggerEvent.AfterToolCallResponseReceived); + var agent = new TestChatHistoryAgent(reducer); + var history = new ChatHistory(); + + history.AddUserMessage("User message 1"); + + // Add multiple function calls and results + for (int i = 0; i < 3; i++) + { + history.Add(new ChatMessageContent(AuthorRole.Assistant, [new FunctionCallContent($"test-func-{i}")])); + history.Add(new ChatMessageContent(AuthorRole.Tool, [new FunctionResultContent($"test-func-{i}", $"result-{i}")])); + + // Trigger reduction after each tool call response + await agent.ReduceAsync(history, ChatReducerTriggerEvent.AfterToolCallResponseReceived); + } + + // Assert - the reducer should have been invoked 3 times + Assert.Equal(3, reducer.InvocationCount); + } + + /// + /// Test reducer that counts invocations. + /// + private sealed class CountingReducer : ChatHistoryReducerBase + { + public int InvocationCount { get; private set; } + + public CountingReducer(params ChatReducerTriggerEvent[] triggerEvents) + : base(triggerEvents) + { + } + + public override Task?> ReduceAsync( + IReadOnlyList chatHistory, + CancellationToken cancellationToken = default) + { + this.InvocationCount++; + // Return null to indicate no reduction occurred (for testing purposes) + return Task.FromResult?>(null); + } + } + + /// + /// Test implementation of ChatHistoryAgent for testing purposes. + /// + private sealed class TestChatHistoryAgent : ChatHistoryAgent + { + public TestChatHistoryAgent(IChatHistoryReducer? reducer = null) + { + this.HistoryReducer = reducer; + } + + protected internal override async IAsyncEnumerable InvokeAsync( + ChatHistory history, + KernelArguments? arguments = null, + Kernel? kernel = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + // Simple test implementation: return a single response + yield return new ChatMessageContent(AuthorRole.Assistant, "Test response"); + await Task.CompletedTask; + } + + protected internal override async IAsyncEnumerable InvokeStreamingAsync( + ChatHistory history, + KernelArguments? arguments = null, + Kernel? kernel = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + // Simple test implementation: return a single streaming response + yield return new StreamingChatMessageContent(AuthorRole.Assistant, "Test response"); + await Task.CompletedTask; + } + } +} diff --git a/dotnet/src/Agents/UnitTests/Core/ChatReducerTriggerEventTests.cs b/dotnet/src/Agents/UnitTests/Core/ChatReducerTriggerEventTests.cs new file mode 100644 index 000000000000..1411f0e9af5f --- /dev/null +++ b/dotnet/src/Agents/UnitTests/Core/ChatReducerTriggerEventTests.cs @@ -0,0 +1,123 @@ +// Copyright (c) Microsoft. All rights reserved. +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.SemanticKernel.ChatCompletion; +using Xunit; + +namespace SemanticKernel.Agents.UnitTests.Core; + +/// +/// Unit testing of and related trigger-aware reducer functionality. +/// +public class ChatReducerTriggerEventTests +{ + /// + /// Verify that ChatReducerTriggerEvent enum has the expected values. + /// + [Fact] + public void VerifyChatReducerTriggerEventValues() + { + // Assert - verify all expected trigger events exist + Assert.True(System.Enum.IsDefined(typeof(ChatReducerTriggerEvent), ChatReducerTriggerEvent.AfterMessageAdded)); + Assert.True(System.Enum.IsDefined(typeof(ChatReducerTriggerEvent), ChatReducerTriggerEvent.BeforeMessagesRetrieval)); + Assert.True(System.Enum.IsDefined(typeof(ChatReducerTriggerEvent), ChatReducerTriggerEvent.AfterToolCallResponseReceived)); + } + + /// + /// Verify that a trigger-aware reducer responds to configured trigger events. + /// + [Fact] + public async Task VerifyTriggerAwareReducerRespondsToConfiguredEventsAsync() + { + // Arrange + var reducer = new TestTriggerAwareReducer(ChatReducerTriggerEvent.AfterToolCallResponseReceived); + var history = new List + { + new(AuthorRole.User, "Test message 1"), + new(AuthorRole.Assistant, "Test response 1"), + new(AuthorRole.User, "Test message 2") + }; + + // Act - should trigger + var shouldTrigger = reducer.ShouldTriggerOn(ChatReducerTriggerEvent.AfterToolCallResponseReceived); + + // Assert + Assert.True(shouldTrigger); + Assert.Single(reducer.TriggerEvents); + Assert.Contains(ChatReducerTriggerEvent.AfterToolCallResponseReceived, reducer.TriggerEvents); + } + + /// + /// Verify that a trigger-aware reducer does not respond to non-configured trigger events. + /// + [Fact] + public void VerifyTriggerAwareReducerIgnoresNonConfiguredEvents() + { + // Arrange + var reducer = new TestTriggerAwareReducer(ChatReducerTriggerEvent.AfterToolCallResponseReceived); + + // Act & Assert - should not trigger for other events + Assert.False(reducer.ShouldTriggerOn(ChatReducerTriggerEvent.AfterMessageAdded)); + Assert.False(reducer.ShouldTriggerOn(ChatReducerTriggerEvent.BeforeMessagesRetrieval)); + } + + /// + /// Verify that a trigger-aware reducer can be configured for multiple events. + /// + [Fact] + public void VerifyTriggerAwareReducerSupportsMultipleEvents() + { + // Arrange + var reducer = new TestTriggerAwareReducer( + ChatReducerTriggerEvent.AfterToolCallResponseReceived, + ChatReducerTriggerEvent.BeforeMessagesRetrieval); + + // Act & Assert + Assert.True(reducer.ShouldTriggerOn(ChatReducerTriggerEvent.AfterToolCallResponseReceived)); + Assert.True(reducer.ShouldTriggerOn(ChatReducerTriggerEvent.BeforeMessagesRetrieval)); + Assert.False(reducer.ShouldTriggerOn(ChatReducerTriggerEvent.AfterMessageAdded)); + Assert.Equal(2, reducer.TriggerEvents.Count); + } + + /// + /// Verify that a reducer without triggers defaults to BeforeMessagesRetrieval. + /// + [Fact] + public void VerifyReducerDefaultsTriggerToBeforeMessagesRetrieval() + { + // Arrange & Act + var reducer = new TestTriggerAwareReducer(); + + // Assert + Assert.Single(reducer.TriggerEvents); + Assert.Contains(ChatReducerTriggerEvent.BeforeMessagesRetrieval, reducer.TriggerEvents); + Assert.True(reducer.ShouldTriggerOn(ChatReducerTriggerEvent.BeforeMessagesRetrieval)); + } + + /// + /// Test implementation of a trigger-aware reducer for testing purposes. + /// + private sealed class TestTriggerAwareReducer : ChatHistoryReducerBase + { + public TestTriggerAwareReducer(params ChatReducerTriggerEvent[] triggerEvents) + : base(triggerEvents) + { + } + + public override Task?> ReduceAsync( + IReadOnlyList chatHistory, + CancellationToken cancellationToken = default) + { + // Simple test implementation: keep only the last 2 messages + if (chatHistory.Count > 2) + { + return Task.FromResult?>( + chatHistory.Skip(chatHistory.Count - 2).ToList()); + } + + return Task.FromResult?>(null); + } + } +} diff --git a/dotnet/src/SemanticKernel.Abstractions/AI/ChatCompletion/ChatHistoryReducerBase.cs b/dotnet/src/SemanticKernel.Abstractions/AI/ChatCompletion/ChatHistoryReducerBase.cs new file mode 100644 index 000000000000..47d7b94ead51 --- /dev/null +++ b/dotnet/src/SemanticKernel.Abstractions/AI/ChatCompletion/ChatHistoryReducerBase.cs @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Linq; + +namespace Microsoft.SemanticKernel.ChatCompletion; + +/// +/// Abstract base class for implementing trigger-aware chat history reducers. +/// +public abstract class ChatHistoryReducerBase : IChatHistoryReducerWithTrigger +{ + /// + public IReadOnlyCollection TriggerEvents { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The events that should trigger this reducer. Defaults to BeforeMessagesRetrieval if not specified. + protected ChatHistoryReducerBase(params ChatReducerTriggerEvent[] triggerEvents) + { + this.TriggerEvents = triggerEvents.Length > 0 + ? triggerEvents.ToArray() + : new[] { ChatReducerTriggerEvent.BeforeMessagesRetrieval }; + } + + /// + public bool ShouldTriggerOn(ChatReducerTriggerEvent triggerEvent) + { + return this.TriggerEvents.Contains(triggerEvent); + } + + /// + public abstract System.Threading.Tasks.Task?> ReduceAsync( + IReadOnlyList chatHistory, + System.Threading.CancellationToken cancellationToken = default); +} diff --git a/dotnet/src/SemanticKernel.Abstractions/AI/ChatCompletion/ChatReducerTriggerEvent.cs b/dotnet/src/SemanticKernel.Abstractions/AI/ChatCompletion/ChatReducerTriggerEvent.cs new file mode 100644 index 000000000000..cf19074b5e88 --- /dev/null +++ b/dotnet/src/SemanticKernel.Abstractions/AI/ChatCompletion/ChatReducerTriggerEvent.cs @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.SemanticKernel.ChatCompletion; + +/// +/// Defines the events that can trigger a reducer in the chat history management. +/// +public enum ChatReducerTriggerEvent +{ + /// + /// Trigger the reducer when a new message is added. + /// + /// + /// This trigger occurs after a message is added to the chat history, allowing the reducer + /// to process the messages before they are used in subsequent operations. + /// + AfterMessageAdded, + + /// + /// Trigger the reducer before messages are retrieved from the provider. + /// + /// + /// This trigger occurs just before the chat history is passed to the model provider, + /// allowing the reducer to trim or process messages before they are sent. + /// + BeforeMessagesRetrieval, + + /// + /// Trigger the reducer after each tool call response is received from the provider. + /// + /// + /// This trigger occurs each time a tool/function result is received (FunctionResultContent), + /// allowing the reducer to manage history size during long-running agentic workflows with + /// multiple tool calls that could exceed token limits. + /// + AfterToolCallResponseReceived +} diff --git a/dotnet/src/SemanticKernel.Abstractions/AI/ChatCompletion/IChatHistoryReducerWithTrigger.cs b/dotnet/src/SemanticKernel.Abstractions/AI/ChatCompletion/IChatHistoryReducerWithTrigger.cs new file mode 100644 index 000000000000..2cf1e8474ce0 --- /dev/null +++ b/dotnet/src/SemanticKernel.Abstractions/AI/ChatCompletion/IChatHistoryReducerWithTrigger.cs @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.SemanticKernel.ChatCompletion; + +/// +/// Interface for reducing chat history with support for trigger-based reduction. +/// +/// +/// This interface extends by allowing reducers to be +/// configured with specific trigger events that determine when the reduction should occur. +/// +public interface IChatHistoryReducerWithTrigger : IChatHistoryReducer +{ + /// + /// Gets the trigger events that should invoke this reducer. + /// + /// + /// A reducer can be configured to respond to one or more trigger events. + /// If multiple events are specified, the reducer will be invoked on any of those events. + /// + IReadOnlyCollection TriggerEvents { get; } + + /// + /// Determines if the reducer should be invoked for the specified trigger event. + /// + /// The trigger event being evaluated. + /// if the reducer should be invoked; otherwise, . + bool ShouldTriggerOn(ChatReducerTriggerEvent triggerEvent); +} From e7987122707e263e5f3024436c31b95d19ee8151 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Feb 2026 09:31:59 +0000 Subject: [PATCH 3/5] Fix namespace imports and build errors in tests Co-authored-by: markwallace-microsoft <127216156+markwallace-microsoft@users.noreply.github.com> --- .../AfterToolCallResponseReceivedTests.cs | 40 ++----------------- .../Core/ChatReducerTriggerEventTests.cs | 1 + .../ChatCompletion/ChatHistoryReducerBase.cs | 7 +++- 3 files changed, 9 insertions(+), 39 deletions(-) diff --git a/dotnet/src/Agents/UnitTests/Core/AfterToolCallResponseReceivedTests.cs b/dotnet/src/Agents/UnitTests/Core/AfterToolCallResponseReceivedTests.cs index 6b55ab2cb75f..b7e6d285950d 100644 --- a/dotnet/src/Agents/UnitTests/Core/AfterToolCallResponseReceivedTests.cs +++ b/dotnet/src/Agents/UnitTests/Core/AfterToolCallResponseReceivedTests.cs @@ -1,7 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. using System.Collections.Generic; using System.Linq; -using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; using Microsoft.SemanticKernel; @@ -24,7 +23,7 @@ public async Task VerifyAfterToolCallResponseReceivedTriggerFiresAsync() { // Arrange var reducer = new CountingReducer(ChatReducerTriggerEvent.AfterToolCallResponseReceived); - var agent = new TestChatHistoryAgent(reducer); + var agent = new MockAgent { HistoryReducer = reducer }; var history = new ChatHistory(); history.AddUserMessage("User message 1"); @@ -49,7 +48,7 @@ public async Task VerifyAfterToolCallResponseReceivedTriggerDoesNotFireForNonToo { // Arrange var reducer = new CountingReducer(ChatReducerTriggerEvent.AfterToolCallResponseReceived); - var agent = new TestChatHistoryAgent(reducer); + var agent = new MockAgent { HistoryReducer = reducer }; var history = new ChatHistory(); history.AddUserMessage("User message 1"); @@ -70,7 +69,7 @@ public async Task VerifyMultipleToolCallResponsesInvokeReducerMultipleTimesAsync { // Arrange var reducer = new CountingReducer(ChatReducerTriggerEvent.AfterToolCallResponseReceived); - var agent = new TestChatHistoryAgent(reducer); + var agent = new MockAgent { HistoryReducer = reducer }; var history = new ChatHistory(); history.AddUserMessage("User message 1"); @@ -110,37 +109,4 @@ public CountingReducer(params ChatReducerTriggerEvent[] triggerEvents) return Task.FromResult?>(null); } } - - /// - /// Test implementation of ChatHistoryAgent for testing purposes. - /// - private sealed class TestChatHistoryAgent : ChatHistoryAgent - { - public TestChatHistoryAgent(IChatHistoryReducer? reducer = null) - { - this.HistoryReducer = reducer; - } - - protected internal override async IAsyncEnumerable InvokeAsync( - ChatHistory history, - KernelArguments? arguments = null, - Kernel? kernel = null, - [EnumeratorCancellation] CancellationToken cancellationToken = default) - { - // Simple test implementation: return a single response - yield return new ChatMessageContent(AuthorRole.Assistant, "Test response"); - await Task.CompletedTask; - } - - protected internal override async IAsyncEnumerable InvokeStreamingAsync( - ChatHistory history, - KernelArguments? arguments = null, - Kernel? kernel = null, - [EnumeratorCancellation] CancellationToken cancellationToken = default) - { - // Simple test implementation: return a single streaming response - yield return new StreamingChatMessageContent(AuthorRole.Assistant, "Test response"); - await Task.CompletedTask; - } - } } diff --git a/dotnet/src/Agents/UnitTests/Core/ChatReducerTriggerEventTests.cs b/dotnet/src/Agents/UnitTests/Core/ChatReducerTriggerEventTests.cs index 1411f0e9af5f..da6b5de3c87e 100644 --- a/dotnet/src/Agents/UnitTests/Core/ChatReducerTriggerEventTests.cs +++ b/dotnet/src/Agents/UnitTests/Core/ChatReducerTriggerEventTests.cs @@ -3,6 +3,7 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; +using Microsoft.SemanticKernel; using Microsoft.SemanticKernel.ChatCompletion; using Xunit; diff --git a/dotnet/src/SemanticKernel.Abstractions/AI/ChatCompletion/ChatHistoryReducerBase.cs b/dotnet/src/SemanticKernel.Abstractions/AI/ChatCompletion/ChatHistoryReducerBase.cs index 47d7b94ead51..626af46c28ef 100644 --- a/dotnet/src/SemanticKernel.Abstractions/AI/ChatCompletion/ChatHistoryReducerBase.cs +++ b/dotnet/src/SemanticKernel.Abstractions/AI/ChatCompletion/ChatHistoryReducerBase.cs @@ -2,6 +2,9 @@ using System.Collections.Generic; using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.SemanticKernel; namespace Microsoft.SemanticKernel.ChatCompletion; @@ -31,7 +34,7 @@ public bool ShouldTriggerOn(ChatReducerTriggerEvent triggerEvent) } /// - public abstract System.Threading.Tasks.Task?> ReduceAsync( + public abstract Task?> ReduceAsync( IReadOnlyList chatHistory, - System.Threading.CancellationToken cancellationToken = default); + CancellationToken cancellationToken = default); } From 1672819d1d9998313189012a103fddcef9f2e1a1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Feb 2026 09:34:47 +0000 Subject: [PATCH 4/5] Address code review feedback - remove unused using, fix docs, remove trailing whitespace Co-authored-by: markwallace-microsoft <127216156+markwallace-microsoft@users.noreply.github.com> --- dotnet/src/Agents/Core/ChatHistoryAgent.cs | 2 +- .../AfterToolCallResponseReceivedTests.cs | 22 +++++++++---------- .../Core/ChatReducerTriggerEventTests.cs | 2 +- .../ChatCompletion/ChatHistoryReducerBase.cs | 1 - 4 files changed, 13 insertions(+), 14 deletions(-) diff --git a/dotnet/src/Agents/Core/ChatHistoryAgent.cs b/dotnet/src/Agents/Core/ChatHistoryAgent.cs index 92b9fb942184..dcdbd60402d4 100644 --- a/dotnet/src/Agents/Core/ChatHistoryAgent.cs +++ b/dotnet/src/Agents/Core/ChatHistoryAgent.cs @@ -25,7 +25,7 @@ public abstract class ChatHistoryAgent : Agent /// /// /// The reducer is automatically applied to the history before invoking the agent, only when using - /// an . It must be explicitly applied via . + /// an . For manual control, use the ReduceAsync methods. /// [Experimental("SKEXP0110")] public IChatHistoryReducer? HistoryReducer { get; init; } diff --git a/dotnet/src/Agents/UnitTests/Core/AfterToolCallResponseReceivedTests.cs b/dotnet/src/Agents/UnitTests/Core/AfterToolCallResponseReceivedTests.cs index b7e6d285950d..b0d1a460e558 100644 --- a/dotnet/src/Agents/UnitTests/Core/AfterToolCallResponseReceivedTests.cs +++ b/dotnet/src/Agents/UnitTests/Core/AfterToolCallResponseReceivedTests.cs @@ -25,17 +25,17 @@ public async Task VerifyAfterToolCallResponseReceivedTriggerFiresAsync() var reducer = new CountingReducer(ChatReducerTriggerEvent.AfterToolCallResponseReceived); var agent = new MockAgent { HistoryReducer = reducer }; var history = new ChatHistory(); - + history.AddUserMessage("User message 1"); history.AddAssistantMessage("Assistant response 1"); - + // Add a function call and result history.Add(new ChatMessageContent(AuthorRole.Assistant, [new FunctionCallContent("test-func")])); history.Add(new ChatMessageContent(AuthorRole.Tool, [new FunctionResultContent("test-func", "result")])); - + // Act - trigger reduction with the AfterToolCallResponseReceived event await agent.ReduceAsync(history, ChatReducerTriggerEvent.AfterToolCallResponseReceived); - + // Assert - the reducer should have been invoked Assert.Equal(1, reducer.InvocationCount); } @@ -50,13 +50,13 @@ public async Task VerifyAfterToolCallResponseReceivedTriggerDoesNotFireForNonToo var reducer = new CountingReducer(ChatReducerTriggerEvent.AfterToolCallResponseReceived); var agent = new MockAgent { HistoryReducer = reducer }; var history = new ChatHistory(); - + history.AddUserMessage("User message 1"); history.AddAssistantMessage("Assistant response 1"); - + // Act - trigger reduction with BeforeMessagesRetrieval (not the configured trigger) await agent.ReduceAsync(history, ChatReducerTriggerEvent.BeforeMessagesRetrieval); - + // Assert - the reducer should NOT have been invoked because it's only configured for AfterToolCallResponseReceived Assert.Equal(0, reducer.InvocationCount); } @@ -71,19 +71,19 @@ public async Task VerifyMultipleToolCallResponsesInvokeReducerMultipleTimesAsync var reducer = new CountingReducer(ChatReducerTriggerEvent.AfterToolCallResponseReceived); var agent = new MockAgent { HistoryReducer = reducer }; var history = new ChatHistory(); - + history.AddUserMessage("User message 1"); - + // Add multiple function calls and results for (int i = 0; i < 3; i++) { history.Add(new ChatMessageContent(AuthorRole.Assistant, [new FunctionCallContent($"test-func-{i}")])); history.Add(new ChatMessageContent(AuthorRole.Tool, [new FunctionResultContent($"test-func-{i}", $"result-{i}")])); - + // Trigger reduction after each tool call response await agent.ReduceAsync(history, ChatReducerTriggerEvent.AfterToolCallResponseReceived); } - + // Assert - the reducer should have been invoked 3 times Assert.Equal(3, reducer.InvocationCount); } diff --git a/dotnet/src/Agents/UnitTests/Core/ChatReducerTriggerEventTests.cs b/dotnet/src/Agents/UnitTests/Core/ChatReducerTriggerEventTests.cs index da6b5de3c87e..be9419417994 100644 --- a/dotnet/src/Agents/UnitTests/Core/ChatReducerTriggerEventTests.cs +++ b/dotnet/src/Agents/UnitTests/Core/ChatReducerTriggerEventTests.cs @@ -43,7 +43,7 @@ public async Task VerifyTriggerAwareReducerRespondsToConfiguredEventsAsync() // Act - should trigger var shouldTrigger = reducer.ShouldTriggerOn(ChatReducerTriggerEvent.AfterToolCallResponseReceived); - + // Assert Assert.True(shouldTrigger); Assert.Single(reducer.TriggerEvents); diff --git a/dotnet/src/SemanticKernel.Abstractions/AI/ChatCompletion/ChatHistoryReducerBase.cs b/dotnet/src/SemanticKernel.Abstractions/AI/ChatCompletion/ChatHistoryReducerBase.cs index 626af46c28ef..d0b77ddc506c 100644 --- a/dotnet/src/SemanticKernel.Abstractions/AI/ChatCompletion/ChatHistoryReducerBase.cs +++ b/dotnet/src/SemanticKernel.Abstractions/AI/ChatCompletion/ChatHistoryReducerBase.cs @@ -4,7 +4,6 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; -using Microsoft.SemanticKernel; namespace Microsoft.SemanticKernel.ChatCompletion; From 5fe65037d089580c0dbce4dfe11d48a73c814ea4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Feb 2026 09:36:40 +0000 Subject: [PATCH 5/5] Add example demonstrating AfterToolCallReducerExample usage Co-authored-by: markwallace-microsoft <127216156+markwallace-microsoft@users.noreply.github.com> --- .../AfterToolCallReducerExample.cs | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 dotnet/samples/Concepts/ChatCompletion/ChatHistoryReducers/AfterToolCallReducerExample.cs diff --git a/dotnet/samples/Concepts/ChatCompletion/ChatHistoryReducers/AfterToolCallReducerExample.cs b/dotnet/samples/Concepts/ChatCompletion/ChatHistoryReducers/AfterToolCallReducerExample.cs new file mode 100644 index 000000000000..f186a155c84b --- /dev/null +++ b/dotnet/samples/Concepts/ChatCompletion/ChatHistoryReducers/AfterToolCallReducerExample.cs @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.ChatCompletion; + +namespace ChatCompletion; + +/// +/// Example reducer that demonstrates usage of the AfterToolCallResponseReceived trigger. +/// This reducer keeps only the last N messages and triggers after each tool call response. +/// +/// +/// This is useful for long-running agentic workflows where multiple tool calls +/// can cause the chat history to exceed token limits. +/// +public sealed class AfterToolCallReducerExample : ChatHistoryReducerBase +{ + private readonly int _maxMessages; + + /// + /// Creates a new instance of . + /// + /// Maximum number of messages to keep in history. + public AfterToolCallReducerExample(int maxMessages) + : base(ChatReducerTriggerEvent.AfterToolCallResponseReceived) + { + this._maxMessages = maxMessages; + } + + /// + public override Task?> ReduceAsync( + IReadOnlyList chatHistory, + CancellationToken cancellationToken = default) + { + // If history is within limits, no reduction needed + if (chatHistory.Count <= this._maxMessages) + { + return Task.FromResult?>(null); + } + + // Keep only the last N messages + var reducedHistory = chatHistory.Skip(chatHistory.Count - this._maxMessages).ToList(); + return Task.FromResult?>(reducedHistory); + } +}