Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Example reducer that demonstrates usage of the AfterToolCallResponseReceived trigger.
/// This reducer keeps only the last N messages and triggers after each tool call response.
/// </summary>
/// <remarks>
/// This is useful for long-running agentic workflows where multiple tool calls
/// can cause the chat history to exceed token limits.
/// </remarks>
public sealed class AfterToolCallReducerExample : ChatHistoryReducerBase
{
private readonly int _maxMessages;

/// <summary>
/// Creates a new instance of <see cref="AfterToolCallReducerExample"/>.
/// </summary>
/// <param name="maxMessages">Maximum number of messages to keep in history.</param>
public AfterToolCallReducerExample(int maxMessages)
: base(ChatReducerTriggerEvent.AfterToolCallResponseReceived)
{
this._maxMessages = maxMessages;
}

/// <inheritdoc/>
public override Task<IEnumerable<ChatMessageContent>?> ReduceAsync(
IReadOnlyList<ChatMessageContent> chatHistory,
CancellationToken cancellationToken = default)
{
// If history is within limits, no reduction needed
if (chatHistory.Count <= this._maxMessages)
{
return Task.FromResult<IEnumerable<ChatMessageContent>?>(null);
}

// Keep only the last N messages
var reducedHistory = chatHistory.Skip(chatHistory.Count - this._maxMessages).ToList();
return Task.FromResult<IEnumerable<ChatMessageContent>?>(reducedHistory);
}
}
24 changes: 23 additions & 1 deletion dotnet/src/Agents/Core/ChatHistoryAgent.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ public abstract class ChatHistoryAgent : Agent
/// </summary>
/// <remarks>
/// The reducer is automatically applied to the history before invoking the agent, only when using
/// an <see cref="AgentChat"/>. It must be explicitly applied via <see cref="ReduceAsync"/>.
/// an <see cref="AgentChat"/>. For manual control, use the <c>ReduceAsync</c> methods.
/// </remarks>
[Experimental("SKEXP0110")]
public IChatHistoryReducer? HistoryReducer { get; init; }
Expand Down Expand Up @@ -68,6 +68,28 @@ protected internal abstract IAsyncEnumerable<StreamingChatMessageContent> Invoke
public Task<bool> ReduceAsync(ChatHistory history, CancellationToken cancellationToken = default) =>
history.ReduceInPlaceAsync(this.HistoryReducer, cancellationToken);

/// <summary>
/// Reduces the provided history for a specific trigger event.
/// </summary>
/// <param name="history">The source history.</param>
/// <param name="triggerEvent">The trigger event that is invoking the reduction.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns><see langword="true"/> if reduction occurred.</returns>
[Experimental("SKEXP0110")]
public Task<bool> 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);
}

/// <inheritdoc/>
[Experimental("SKEXP0110")]
protected sealed override IEnumerable<string> GetChannelKeys()
Expand Down
20 changes: 16 additions & 4 deletions dotnet/src/Agents/Core/ChatHistoryChannel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -98,8 +104,8 @@ protected override async IAsyncEnumerable<StreamingChatMessageContent> 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;

Expand All @@ -111,6 +117,12 @@ protected override async IAsyncEnumerable<StreamingChatMessageContent> 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);
}
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Integration tests for <see cref="ChatReducerTriggerEvent.AfterToolCallResponseReceived"/>.
/// </summary>
public class AfterToolCallResponseReceivedTests
{
/// <summary>
/// Verify that the AfterToolCallResponseReceived trigger fires when function results are received.
/// </summary>
[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);
}

/// <summary>
/// Verify that the AfterToolCallResponseReceived trigger does not fire for non-tool messages.
/// </summary>
[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);
}

/// <summary>
/// Verify that the reducer is invoked multiple times for multiple tool call responses.
/// </summary>
[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);
}

/// <summary>
/// Test reducer that counts invocations.
/// </summary>
private sealed class CountingReducer : ChatHistoryReducerBase
{
public int InvocationCount { get; private set; }

public CountingReducer(params ChatReducerTriggerEvent[] triggerEvents)
: base(triggerEvents)
{
}

public override Task<IEnumerable<ChatMessageContent>?> ReduceAsync(
IReadOnlyList<ChatMessageContent> chatHistory,
CancellationToken cancellationToken = default)
{
this.InvocationCount++;
// Return null to indicate no reduction occurred (for testing purposes)
return Task.FromResult<IEnumerable<ChatMessageContent>?>(null);
}
}
}
124 changes: 124 additions & 0 deletions dotnet/src/Agents/UnitTests/Core/ChatReducerTriggerEventTests.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Unit testing of <see cref="ChatReducerTriggerEvent"/> and related trigger-aware reducer functionality.
/// </summary>
public class ChatReducerTriggerEventTests
{
/// <summary>
/// Verify that ChatReducerTriggerEvent enum has the expected values.
/// </summary>
[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));
}

/// <summary>
/// Verify that a trigger-aware reducer responds to configured trigger events.
/// </summary>
[Fact]
public async Task VerifyTriggerAwareReducerRespondsToConfiguredEventsAsync()
{
// Arrange
var reducer = new TestTriggerAwareReducer(ChatReducerTriggerEvent.AfterToolCallResponseReceived);
var history = new List<ChatMessageContent>
{
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);
}

/// <summary>
/// Verify that a trigger-aware reducer does not respond to non-configured trigger events.
/// </summary>
[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));
}

/// <summary>
/// Verify that a trigger-aware reducer can be configured for multiple events.
/// </summary>
[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);
}

/// <summary>
/// Verify that a reducer without triggers defaults to BeforeMessagesRetrieval.
/// </summary>
[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));
}

/// <summary>
/// Test implementation of a trigger-aware reducer for testing purposes.
/// </summary>
private sealed class TestTriggerAwareReducer : ChatHistoryReducerBase
{
public TestTriggerAwareReducer(params ChatReducerTriggerEvent[] triggerEvents)
: base(triggerEvents)
{
}

public override Task<IEnumerable<ChatMessageContent>?> ReduceAsync(
IReadOnlyList<ChatMessageContent> chatHistory,
CancellationToken cancellationToken = default)
{
// Simple test implementation: keep only the last 2 messages
if (chatHistory.Count > 2)
{
return Task.FromResult<IEnumerable<ChatMessageContent>?>(
chatHistory.Skip(chatHistory.Count - 2).ToList());
}

return Task.FromResult<IEnumerable<ChatMessageContent>?>(null);
}
}
}
Loading