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);
+ }
+}
diff --git a/dotnet/src/Agents/Core/ChatHistoryAgent.cs b/dotnet/src/Agents/Core/ChatHistoryAgent.cs
index e3e8f3a2410c..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; }
@@ -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..b0d1a460e558
--- /dev/null
+++ b/dotnet/src/Agents/UnitTests/Core/AfterToolCallResponseReceivedTests.cs
@@ -0,0 +1,112 @@
+// 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.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 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);
+ }
+
+ ///
+ /// 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 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);
+ }
+
+ ///
+ /// 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 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);
+ }
+
+ ///
+ /// 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);
+ }
+ }
+}
diff --git a/dotnet/src/Agents/UnitTests/Core/ChatReducerTriggerEventTests.cs b/dotnet/src/Agents/UnitTests/Core/ChatReducerTriggerEventTests.cs
new file mode 100644
index 000000000000..be9419417994
--- /dev/null
+++ b/dotnet/src/Agents/UnitTests/Core/ChatReducerTriggerEventTests.cs
@@ -0,0 +1,124 @@
+// 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;
+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..d0b77ddc506c
--- /dev/null
+++ b/dotnet/src/SemanticKernel.Abstractions/AI/ChatCompletion/ChatHistoryReducerBase.cs
@@ -0,0 +1,39 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+
+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 Task?> ReduceAsync(
+ IReadOnlyList chatHistory,
+ 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);
+}