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