Skip to content

Commit d419821

Browse files
Add ChatReducerTriggerEvent enum and trigger-aware reducer support
Co-authored-by: markwallace-microsoft <127216156+markwallace-microsoft@users.noreply.github.com>
1 parent 3ca44a6 commit d419821

File tree

7 files changed

+415
-5
lines changed

7 files changed

+415
-5
lines changed

dotnet/src/Agents/Core/ChatHistoryAgent.cs

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ public abstract class ChatHistoryAgent : Agent
2525
/// </summary>
2626
/// <remarks>
2727
/// The reducer is automatically applied to the history before invoking the agent, only when using
28-
/// an <see cref="AgentChat"/>. It must be explicitly applied via <see cref="ReduceAsync"/>.
28+
/// an <see cref="AgentChat"/>. It must be explicitly applied via <see cref="ReduceAsync(ChatHistory, CancellationToken)"/>.
2929
/// </remarks>
3030
[Experimental("SKEXP0110")]
3131
public IChatHistoryReducer? HistoryReducer { get; init; }
@@ -68,6 +68,28 @@ protected internal abstract IAsyncEnumerable<StreamingChatMessageContent> Invoke
6868
public Task<bool> ReduceAsync(ChatHistory history, CancellationToken cancellationToken = default) =>
6969
history.ReduceInPlaceAsync(this.HistoryReducer, cancellationToken);
7070

71+
/// <summary>
72+
/// Reduces the provided history for a specific trigger event.
73+
/// </summary>
74+
/// <param name="history">The source history.</param>
75+
/// <param name="triggerEvent">The trigger event that is invoking the reduction.</param>
76+
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
77+
/// <returns><see langword="true"/> if reduction occurred.</returns>
78+
[Experimental("SKEXP0110")]
79+
public Task<bool> ReduceAsync(ChatHistory history, ChatReducerTriggerEvent triggerEvent, CancellationToken cancellationToken = default)
80+
{
81+
// If the reducer supports triggers, only reduce if it's configured for this trigger
82+
if (this.HistoryReducer is IChatHistoryReducerWithTrigger triggerReducer)
83+
{
84+
if (!triggerReducer.ShouldTriggerOn(triggerEvent))
85+
{
86+
return Task.FromResult(false);
87+
}
88+
}
89+
90+
return history.ReduceInPlaceAsync(this.HistoryReducer, cancellationToken);
91+
}
92+
7193
/// <inheritdoc/>
7294
[Experimental("SKEXP0110")]
7395
protected sealed override IEnumerable<string> GetChannelKeys()

dotnet/src/Agents/Core/ChatHistoryChannel.cs

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,8 @@ internal sealed class ChatHistoryChannel : AgentChannel
4040
throw new KernelException($"Invalid channel binding for agent: {agent.Id} ({agent.GetType().FullName})");
4141
}
4242

43-
// Pre-process history reduction.
44-
await historyAgent.ReduceAsync(this._history, cancellationToken).ConfigureAwait(false);
43+
// Pre-process history reduction with BeforeMessagesRetrieval trigger.
44+
await historyAgent.ReduceAsync(this._history, ChatReducerTriggerEvent.BeforeMessagesRetrieval, cancellationToken).ConfigureAwait(false);
4545

4646
// Capture the current message count to evaluate history mutation.
4747
int messageCount = this._history.Count;
@@ -71,6 +71,12 @@ internal sealed class ChatHistoryChannel : AgentChannel
7171
messageQueue.Enqueue(responseMessage);
7272
}
7373

74+
// Check if this message contains a function result and trigger reduction if configured
75+
if (responseMessage.Items.Any(i => i is FunctionResultContent))
76+
{
77+
await historyAgent.ReduceAsync(this._history, ChatReducerTriggerEvent.AfterToolCallResponseReceived, cancellationToken).ConfigureAwait(false);
78+
}
79+
7480
// Dequeue the next message to yield.
7581
yieldMessage = messageQueue.Dequeue();
7682
yield return (IsMessageVisible(yieldMessage), yieldMessage);
@@ -98,8 +104,8 @@ protected override async IAsyncEnumerable<StreamingChatMessageContent> InvokeStr
98104
throw new KernelException($"Invalid channel binding for agent: {agent.Id} ({agent.GetType().FullName})");
99105
}
100106

101-
// Pre-process history reduction.
102-
await historyAgent.ReduceAsync(this._history, cancellationToken).ConfigureAwait(false);
107+
// Pre-process history reduction with BeforeMessagesRetrieval trigger.
108+
await historyAgent.ReduceAsync(this._history, ChatReducerTriggerEvent.BeforeMessagesRetrieval, cancellationToken).ConfigureAwait(false);
103109

104110
int messageCount = this._history.Count;
105111

@@ -111,6 +117,12 @@ protected override async IAsyncEnumerable<StreamingChatMessageContent> InvokeStr
111117
for (int index = messageCount; index < this._history.Count; ++index)
112118
{
113119
messages.Add(this._history[index]);
120+
121+
// Check if this message contains a function result and trigger reduction if configured
122+
if (this._history[index].Items.Any(i => i is FunctionResultContent))
123+
{
124+
await historyAgent.ReduceAsync(this._history, ChatReducerTriggerEvent.AfterToolCallResponseReceived, cancellationToken).ConfigureAwait(false);
125+
}
114126
}
115127
}
116128

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
// Copyright (c) Microsoft. All rights reserved.
2+
using System.Collections.Generic;
3+
using System.Linq;
4+
using System.Runtime.CompilerServices;
5+
using System.Threading;
6+
using System.Threading.Tasks;
7+
using Microsoft.SemanticKernel;
8+
using Microsoft.SemanticKernel.Agents;
9+
using Microsoft.SemanticKernel.ChatCompletion;
10+
using Xunit;
11+
12+
namespace SemanticKernel.Agents.UnitTests.Core;
13+
14+
/// <summary>
15+
/// Integration tests for <see cref="ChatReducerTriggerEvent.AfterToolCallResponseReceived"/>.
16+
/// </summary>
17+
public class AfterToolCallResponseReceivedTests
18+
{
19+
/// <summary>
20+
/// Verify that the AfterToolCallResponseReceived trigger fires when function results are received.
21+
/// </summary>
22+
[Fact]
23+
public async Task VerifyAfterToolCallResponseReceivedTriggerFiresAsync()
24+
{
25+
// Arrange
26+
var reducer = new CountingReducer(ChatReducerTriggerEvent.AfterToolCallResponseReceived);
27+
var agent = new TestChatHistoryAgent(reducer);
28+
var history = new ChatHistory();
29+
30+
history.AddUserMessage("User message 1");
31+
history.AddAssistantMessage("Assistant response 1");
32+
33+
// Add a function call and result
34+
history.Add(new ChatMessageContent(AuthorRole.Assistant, [new FunctionCallContent("test-func")]));
35+
history.Add(new ChatMessageContent(AuthorRole.Tool, [new FunctionResultContent("test-func", "result")]));
36+
37+
// Act - trigger reduction with the AfterToolCallResponseReceived event
38+
await agent.ReduceAsync(history, ChatReducerTriggerEvent.AfterToolCallResponseReceived);
39+
40+
// Assert - the reducer should have been invoked
41+
Assert.Equal(1, reducer.InvocationCount);
42+
}
43+
44+
/// <summary>
45+
/// Verify that the AfterToolCallResponseReceived trigger does not fire for non-tool messages.
46+
/// </summary>
47+
[Fact]
48+
public async Task VerifyAfterToolCallResponseReceivedTriggerDoesNotFireForNonToolMessagesAsync()
49+
{
50+
// Arrange
51+
var reducer = new CountingReducer(ChatReducerTriggerEvent.AfterToolCallResponseReceived);
52+
var agent = new TestChatHistoryAgent(reducer);
53+
var history = new ChatHistory();
54+
55+
history.AddUserMessage("User message 1");
56+
history.AddAssistantMessage("Assistant response 1");
57+
58+
// Act - trigger reduction with BeforeMessagesRetrieval (not the configured trigger)
59+
await agent.ReduceAsync(history, ChatReducerTriggerEvent.BeforeMessagesRetrieval);
60+
61+
// Assert - the reducer should NOT have been invoked because it's only configured for AfterToolCallResponseReceived
62+
Assert.Equal(0, reducer.InvocationCount);
63+
}
64+
65+
/// <summary>
66+
/// Verify that the reducer is invoked multiple times for multiple tool call responses.
67+
/// </summary>
68+
[Fact]
69+
public async Task VerifyMultipleToolCallResponsesInvokeReducerMultipleTimesAsync()
70+
{
71+
// Arrange
72+
var reducer = new CountingReducer(ChatReducerTriggerEvent.AfterToolCallResponseReceived);
73+
var agent = new TestChatHistoryAgent(reducer);
74+
var history = new ChatHistory();
75+
76+
history.AddUserMessage("User message 1");
77+
78+
// Add multiple function calls and results
79+
for (int i = 0; i < 3; i++)
80+
{
81+
history.Add(new ChatMessageContent(AuthorRole.Assistant, [new FunctionCallContent($"test-func-{i}")]));
82+
history.Add(new ChatMessageContent(AuthorRole.Tool, [new FunctionResultContent($"test-func-{i}", $"result-{i}")]));
83+
84+
// Trigger reduction after each tool call response
85+
await agent.ReduceAsync(history, ChatReducerTriggerEvent.AfterToolCallResponseReceived);
86+
}
87+
88+
// Assert - the reducer should have been invoked 3 times
89+
Assert.Equal(3, reducer.InvocationCount);
90+
}
91+
92+
/// <summary>
93+
/// Test reducer that counts invocations.
94+
/// </summary>
95+
private sealed class CountingReducer : ChatHistoryReducerBase
96+
{
97+
public int InvocationCount { get; private set; }
98+
99+
public CountingReducer(params ChatReducerTriggerEvent[] triggerEvents)
100+
: base(triggerEvents)
101+
{
102+
}
103+
104+
public override Task<IEnumerable<ChatMessageContent>?> ReduceAsync(
105+
IReadOnlyList<ChatMessageContent> chatHistory,
106+
CancellationToken cancellationToken = default)
107+
{
108+
this.InvocationCount++;
109+
// Return null to indicate no reduction occurred (for testing purposes)
110+
return Task.FromResult<IEnumerable<ChatMessageContent>?>(null);
111+
}
112+
}
113+
114+
/// <summary>
115+
/// Test implementation of ChatHistoryAgent for testing purposes.
116+
/// </summary>
117+
private sealed class TestChatHistoryAgent : ChatHistoryAgent
118+
{
119+
public TestChatHistoryAgent(IChatHistoryReducer? reducer = null)
120+
{
121+
this.HistoryReducer = reducer;
122+
}
123+
124+
protected internal override async IAsyncEnumerable<ChatMessageContent> InvokeAsync(
125+
ChatHistory history,
126+
KernelArguments? arguments = null,
127+
Kernel? kernel = null,
128+
[EnumeratorCancellation] CancellationToken cancellationToken = default)
129+
{
130+
// Simple test implementation: return a single response
131+
yield return new ChatMessageContent(AuthorRole.Assistant, "Test response");
132+
await Task.CompletedTask;
133+
}
134+
135+
protected internal override async IAsyncEnumerable<StreamingChatMessageContent> InvokeStreamingAsync(
136+
ChatHistory history,
137+
KernelArguments? arguments = null,
138+
Kernel? kernel = null,
139+
[EnumeratorCancellation] CancellationToken cancellationToken = default)
140+
{
141+
// Simple test implementation: return a single streaming response
142+
yield return new StreamingChatMessageContent(AuthorRole.Assistant, "Test response");
143+
await Task.CompletedTask;
144+
}
145+
}
146+
}
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
// Copyright (c) Microsoft. All rights reserved.
2+
using System.Collections.Generic;
3+
using System.Linq;
4+
using System.Threading;
5+
using System.Threading.Tasks;
6+
using Microsoft.SemanticKernel.ChatCompletion;
7+
using Xunit;
8+
9+
namespace SemanticKernel.Agents.UnitTests.Core;
10+
11+
/// <summary>
12+
/// Unit testing of <see cref="ChatReducerTriggerEvent"/> and related trigger-aware reducer functionality.
13+
/// </summary>
14+
public class ChatReducerTriggerEventTests
15+
{
16+
/// <summary>
17+
/// Verify that ChatReducerTriggerEvent enum has the expected values.
18+
/// </summary>
19+
[Fact]
20+
public void VerifyChatReducerTriggerEventValues()
21+
{
22+
// Assert - verify all expected trigger events exist
23+
Assert.True(System.Enum.IsDefined(typeof(ChatReducerTriggerEvent), ChatReducerTriggerEvent.AfterMessageAdded));
24+
Assert.True(System.Enum.IsDefined(typeof(ChatReducerTriggerEvent), ChatReducerTriggerEvent.BeforeMessagesRetrieval));
25+
Assert.True(System.Enum.IsDefined(typeof(ChatReducerTriggerEvent), ChatReducerTriggerEvent.AfterToolCallResponseReceived));
26+
}
27+
28+
/// <summary>
29+
/// Verify that a trigger-aware reducer responds to configured trigger events.
30+
/// </summary>
31+
[Fact]
32+
public async Task VerifyTriggerAwareReducerRespondsToConfiguredEventsAsync()
33+
{
34+
// Arrange
35+
var reducer = new TestTriggerAwareReducer(ChatReducerTriggerEvent.AfterToolCallResponseReceived);
36+
var history = new List<ChatMessageContent>
37+
{
38+
new(AuthorRole.User, "Test message 1"),
39+
new(AuthorRole.Assistant, "Test response 1"),
40+
new(AuthorRole.User, "Test message 2")
41+
};
42+
43+
// Act - should trigger
44+
var shouldTrigger = reducer.ShouldTriggerOn(ChatReducerTriggerEvent.AfterToolCallResponseReceived);
45+
46+
// Assert
47+
Assert.True(shouldTrigger);
48+
Assert.Single(reducer.TriggerEvents);
49+
Assert.Contains(ChatReducerTriggerEvent.AfterToolCallResponseReceived, reducer.TriggerEvents);
50+
}
51+
52+
/// <summary>
53+
/// Verify that a trigger-aware reducer does not respond to non-configured trigger events.
54+
/// </summary>
55+
[Fact]
56+
public void VerifyTriggerAwareReducerIgnoresNonConfiguredEvents()
57+
{
58+
// Arrange
59+
var reducer = new TestTriggerAwareReducer(ChatReducerTriggerEvent.AfterToolCallResponseReceived);
60+
61+
// Act & Assert - should not trigger for other events
62+
Assert.False(reducer.ShouldTriggerOn(ChatReducerTriggerEvent.AfterMessageAdded));
63+
Assert.False(reducer.ShouldTriggerOn(ChatReducerTriggerEvent.BeforeMessagesRetrieval));
64+
}
65+
66+
/// <summary>
67+
/// Verify that a trigger-aware reducer can be configured for multiple events.
68+
/// </summary>
69+
[Fact]
70+
public void VerifyTriggerAwareReducerSupportsMultipleEvents()
71+
{
72+
// Arrange
73+
var reducer = new TestTriggerAwareReducer(
74+
ChatReducerTriggerEvent.AfterToolCallResponseReceived,
75+
ChatReducerTriggerEvent.BeforeMessagesRetrieval);
76+
77+
// Act & Assert
78+
Assert.True(reducer.ShouldTriggerOn(ChatReducerTriggerEvent.AfterToolCallResponseReceived));
79+
Assert.True(reducer.ShouldTriggerOn(ChatReducerTriggerEvent.BeforeMessagesRetrieval));
80+
Assert.False(reducer.ShouldTriggerOn(ChatReducerTriggerEvent.AfterMessageAdded));
81+
Assert.Equal(2, reducer.TriggerEvents.Count);
82+
}
83+
84+
/// <summary>
85+
/// Verify that a reducer without triggers defaults to BeforeMessagesRetrieval.
86+
/// </summary>
87+
[Fact]
88+
public void VerifyReducerDefaultsTriggerToBeforeMessagesRetrieval()
89+
{
90+
// Arrange & Act
91+
var reducer = new TestTriggerAwareReducer();
92+
93+
// Assert
94+
Assert.Single(reducer.TriggerEvents);
95+
Assert.Contains(ChatReducerTriggerEvent.BeforeMessagesRetrieval, reducer.TriggerEvents);
96+
Assert.True(reducer.ShouldTriggerOn(ChatReducerTriggerEvent.BeforeMessagesRetrieval));
97+
}
98+
99+
/// <summary>
100+
/// Test implementation of a trigger-aware reducer for testing purposes.
101+
/// </summary>
102+
private sealed class TestTriggerAwareReducer : ChatHistoryReducerBase
103+
{
104+
public TestTriggerAwareReducer(params ChatReducerTriggerEvent[] triggerEvents)
105+
: base(triggerEvents)
106+
{
107+
}
108+
109+
public override Task<IEnumerable<ChatMessageContent>?> ReduceAsync(
110+
IReadOnlyList<ChatMessageContent> chatHistory,
111+
CancellationToken cancellationToken = default)
112+
{
113+
// Simple test implementation: keep only the last 2 messages
114+
if (chatHistory.Count > 2)
115+
{
116+
return Task.FromResult<IEnumerable<ChatMessageContent>?>(
117+
chatHistory.Skip(chatHistory.Count - 2).ToList());
118+
}
119+
120+
return Task.FromResult<IEnumerable<ChatMessageContent>?>(null);
121+
}
122+
}
123+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
// Copyright (c) Microsoft. All rights reserved.
2+
3+
using System.Collections.Generic;
4+
using System.Linq;
5+
6+
namespace Microsoft.SemanticKernel.ChatCompletion;
7+
8+
/// <summary>
9+
/// Abstract base class for implementing trigger-aware chat history reducers.
10+
/// </summary>
11+
public abstract class ChatHistoryReducerBase : IChatHistoryReducerWithTrigger
12+
{
13+
/// <inheritdoc/>
14+
public IReadOnlyCollection<ChatReducerTriggerEvent> TriggerEvents { get; }
15+
16+
/// <summary>
17+
/// Initializes a new instance of the <see cref="ChatHistoryReducerBase"/> class.
18+
/// </summary>
19+
/// <param name="triggerEvents">The events that should trigger this reducer. Defaults to BeforeMessagesRetrieval if not specified.</param>
20+
protected ChatHistoryReducerBase(params ChatReducerTriggerEvent[] triggerEvents)
21+
{
22+
this.TriggerEvents = triggerEvents.Length > 0
23+
? triggerEvents.ToArray()
24+
: new[] { ChatReducerTriggerEvent.BeforeMessagesRetrieval };
25+
}
26+
27+
/// <inheritdoc/>
28+
public bool ShouldTriggerOn(ChatReducerTriggerEvent triggerEvent)
29+
{
30+
return this.TriggerEvents.Contains(triggerEvent);
31+
}
32+
33+
/// <inheritdoc/>
34+
public abstract System.Threading.Tasks.Task<IEnumerable<ChatMessageContent>?> ReduceAsync(
35+
IReadOnlyList<ChatMessageContent> chatHistory,
36+
System.Threading.CancellationToken cancellationToken = default);
37+
}

0 commit comments

Comments
 (0)