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);
+ }
+}