From 1108901bd3e24e4b0e30c63da3a78a63b979c944 Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Mon, 31 Mar 2025 17:34:37 +0100 Subject: [PATCH 1/5] Add thread retrieval interface to enforce thread retrieval requirement for stateless agents. --- dotnet/src/Agents/Abstractions/Agent.cs | 12 +++++- .../Abstractions/IAgentThreadRetrievable.cs | 41 +++++++++++++++++++ dotnet/src/Agents/AzureAI/AzureAIAgent.cs | 2 + dotnet/src/Agents/Bedrock/BedrockAgent.cs | 4 ++ dotnet/src/Agents/Core/ChatCompletionAgent.cs | 32 ++++++++++----- .../src/Agents/Core/ChatHistoryAgentThread.cs | 2 +- .../src/Agents/OpenAI/OpenAIAssistantAgent.cs | 2 + 7 files changed, 81 insertions(+), 14 deletions(-) create mode 100644 dotnet/src/Agents/Abstractions/IAgentThreadRetrievable.cs diff --git a/dotnet/src/Agents/Abstractions/Agent.cs b/dotnet/src/Agents/Abstractions/Agent.cs index a779ee1d98c6..a3019d5a0fcc 100644 --- a/dotnet/src/Agents/Abstractions/Agent.cs +++ b/dotnet/src/Agents/Abstractions/Agent.cs @@ -266,6 +266,7 @@ public abstract IAsyncEnumerable> private ILogger? _logger; +#pragma warning disable SKEXP0110 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. /// /// Ensures that the thread exists, is of the expected type, and is active, plus adds the provided message to the thread. /// @@ -273,13 +274,14 @@ public abstract IAsyncEnumerable> /// The messages to add to the thread once it is setup. /// The thread to create if it's null, validate it's type if not null, and start if it is not active. /// A callback to use to construct the thread if it's null. + /// true if the thread must implement to allow message retrieval. /// The to monitor for cancellation requests. The default is . /// An async task that completes once all update are complete. - /// protected virtual async Task EnsureThreadExistsWithMessagesAsync( ICollection messages, AgentThread? thread, Func constructThread, + bool requiresThreadRetrieval, CancellationToken cancellationToken) where TThreadType : AgentThread { @@ -290,7 +292,12 @@ protected virtual async Task EnsureThreadExistsWithMessagesAsync EnsureThreadExistsWithMessagesAsync /// Notfiy the given thread that a new message is available. diff --git a/dotnet/src/Agents/Abstractions/IAgentThreadRetrievable.cs b/dotnet/src/Agents/Abstractions/IAgentThreadRetrievable.cs new file mode 100644 index 000000000000..978e9359b1b9 --- /dev/null +++ b/dotnet/src/Agents/Abstractions/IAgentThreadRetrievable.cs @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Threading; + +namespace Microsoft.SemanticKernel.Agents; + +/// +/// Interface for any Semantic Kernel agent threads that allow the messages +/// contained in them to be passed to an agent. +/// +/// +/// +/// types that implement this interface can +/// be used with Agents that do not maintain a server-side chat history +/// and require the entire set of messages, that are needed to generate +/// a response, to be provided to the agent at invocation time. +/// +/// +/// The set of messages returned may be truncated or processed +/// by the as needed before passed to the +/// agent to achieve a scalable and performant solution. +/// +/// +[Experimental("SKEXP0110")] +public interface IAgentThreadRetrievable +{ + /// + /// Asynchronously retrieves all messages to be used for the agent invocation. + /// + /// + /// Messages are returned in ascending chronological order. + /// + /// The to monitor for cancellation requests. The default is . + /// The messages in the thread. + /// The thread has been deleted. + [Experimental("SKEXP0110")] + IAsyncEnumerable GetMessagesAsync(CancellationToken cancellationToken = default); +} diff --git a/dotnet/src/Agents/AzureAI/AzureAIAgent.cs b/dotnet/src/Agents/AzureAI/AzureAIAgent.cs index 0a41a7837b35..900de321858d 100644 --- a/dotnet/src/Agents/AzureAI/AzureAIAgent.cs +++ b/dotnet/src/Agents/AzureAI/AzureAIAgent.cs @@ -184,6 +184,7 @@ public async IAsyncEnumerable> InvokeAsync messages, thread, () => new AzureAIAgentThread(this.Client), + requiresThreadRetrieval: false, cancellationToken).ConfigureAwait(false); var invokeResults = ActivityExtensions.RunWithActivityAsync( @@ -303,6 +304,7 @@ public async IAsyncEnumerable> In messages, thread, () => new AzureAIAgentThread(this.Client), + requiresThreadRetrieval: false, cancellationToken).ConfigureAwait(false); #pragma warning disable CS0618 // Type or member is obsolete diff --git a/dotnet/src/Agents/Bedrock/BedrockAgent.cs b/dotnet/src/Agents/Bedrock/BedrockAgent.cs index 736f509e5c6d..1cc45d621099 100644 --- a/dotnet/src/Agents/Bedrock/BedrockAgent.cs +++ b/dotnet/src/Agents/Bedrock/BedrockAgent.cs @@ -115,6 +115,7 @@ public override async IAsyncEnumerable> In messages, thread, () => new BedrockAgentThread(this.RuntimeClient), + requiresThreadRetrieval: false, cancellationToken).ConfigureAwait(false); // Ensure that the last message provided is a user message @@ -193,6 +194,7 @@ public async IAsyncEnumerable> InvokeAsync [], thread, () => new BedrockAgentThread(this.RuntimeClient), + requiresThreadRetrieval: false, cancellationToken).ConfigureAwait(false); // Configure the agent request with the provided options @@ -347,6 +349,7 @@ public override async IAsyncEnumerable new BedrockAgentThread(this.RuntimeClient), + requiresThreadRetrieval: false, cancellationToken).ConfigureAwait(false); // Ensure that the last message provided is a user message @@ -426,6 +429,7 @@ public async IAsyncEnumerable> In [], thread, () => new BedrockAgentThread(this.RuntimeClient), + requiresThreadRetrieval: false, cancellationToken).ConfigureAwait(false); // Configure the agent request with the provided options diff --git a/dotnet/src/Agents/Core/ChatCompletionAgent.cs b/dotnet/src/Agents/Core/ChatCompletionAgent.cs index 62d334520647..a7a0c25927dc 100644 --- a/dotnet/src/Agents/Core/ChatCompletionAgent.cs +++ b/dotnet/src/Agents/Core/ChatCompletionAgent.cs @@ -67,24 +67,29 @@ public override async IAsyncEnumerable> In { Verify.NotNull(messages); - var chatHistoryAgentThread = await this.EnsureThreadExistsWithMessagesAsync( + // Ensure the thread exists, is updated with our new messages, and is retrievable. + var safeAgentThread = await this.EnsureThreadExistsWithMessagesAsync( messages, thread, () => new ChatHistoryAgentThread(), + requiresThreadRetrieval: true, cancellationToken).ConfigureAwait(false); + var retrievableAgentThread = (IAgentThreadRetrievable)safeAgentThread; - // Invoke Chat Completion with the updated chat history. + // Retrieve the chat history from the thread. var chatHistory = new ChatHistory(); - await foreach (var existingMessage in chatHistoryAgentThread.GetMessagesAsync(cancellationToken).ConfigureAwait(false)) + await foreach (var existingMessage in retrievableAgentThread.GetMessagesAsync(cancellationToken).ConfigureAwait(false)) { chatHistory.Add(existingMessage); } + + // Invoke Chat Completion with the history that already contains our new messages. var invokeResults = this.InternalInvokeAsync( this.GetDisplayName(), chatHistory, async (m) => { - await this.NotifyThreadOfNewMessage(chatHistoryAgentThread, m, cancellationToken).ConfigureAwait(false); + await this.NotifyThreadOfNewMessage(safeAgentThread, m, cancellationToken).ConfigureAwait(false); if (options?.OnNewMessage is not null) { await options.OnNewMessage(m).ConfigureAwait(false); @@ -113,7 +118,7 @@ public override async IAsyncEnumerable> In // since the filter terminated the call, and therefore won't get executed. if (!result.Items.Any(i => i is FunctionCallContent || i is FunctionResultContent)) { - await this.NotifyThreadOfNewMessage(chatHistoryAgentThread, result, cancellationToken).ConfigureAwait(false); + await this.NotifyThreadOfNewMessage(safeAgentThread, result, cancellationToken).ConfigureAwait(false); if (options?.OnNewMessage is not null) { @@ -121,7 +126,7 @@ public override async IAsyncEnumerable> In } } - yield return new(result, chatHistoryAgentThread); + yield return new(result, safeAgentThread); } } @@ -150,25 +155,30 @@ public override async IAsyncEnumerable( messages, thread, () => new ChatHistoryAgentThread(), + requiresThreadRetrieval: true, cancellationToken).ConfigureAwait(false); + var retrievableAgentThread = (IAgentThreadRetrievable)safeAgentThread; - // Invoke Chat Completion with the updated chat history. + // Retrieve the chat history from the thread. var chatHistory = new ChatHistory(); - await foreach (var existingMessage in chatHistoryAgentThread.GetMessagesAsync(cancellationToken).ConfigureAwait(false)) + await foreach (var existingMessage in retrievableAgentThread.GetMessagesAsync(cancellationToken).ConfigureAwait(false)) { chatHistory.Add(existingMessage); } + + // Invoke Chat Completion with the history that already contains our new messages. string agentName = this.GetDisplayName(); var invokeResults = this.InternalInvokeStreamingAsync( agentName, chatHistory, async (m) => { - await this.NotifyThreadOfNewMessage(chatHistoryAgentThread, m, cancellationToken).ConfigureAwait(false); + await this.NotifyThreadOfNewMessage(safeAgentThread, m, cancellationToken).ConfigureAwait(false); if (options?.OnNewMessage is not null) { await options.OnNewMessage(m).ConfigureAwait(false); @@ -181,7 +191,7 @@ public override async IAsyncEnumerable /// Represents a conversation thread based on an instance of that is maanged inside this class. /// -public sealed class ChatHistoryAgentThread : AgentThread +public sealed class ChatHistoryAgentThread : AgentThread, IAgentThreadRetrievable { private readonly ChatHistory _chatHistory = new(); diff --git a/dotnet/src/Agents/OpenAI/OpenAIAssistantAgent.cs b/dotnet/src/Agents/OpenAI/OpenAIAssistantAgent.cs index d5642c496665..d33d465d2cb7 100644 --- a/dotnet/src/Agents/OpenAI/OpenAIAssistantAgent.cs +++ b/dotnet/src/Agents/OpenAI/OpenAIAssistantAgent.cs @@ -401,6 +401,7 @@ public async IAsyncEnumerable> InvokeAsync messages, thread, () => new OpenAIAssistantAgentThread(this.Client), + requiresThreadRetrieval: false, cancellationToken).ConfigureAwait(false); // Create options that use the RunCreationOptions from the options param if provided or @@ -547,6 +548,7 @@ public async IAsyncEnumerable> In messages, thread, () => new OpenAIAssistantAgentThread(this.Client), + requiresThreadRetrieval: false, cancellationToken).ConfigureAwait(false); // Create options that use the RunCreationOptions from the options param if provided or From 406b400b6cfbc2e82c76c5130e7db744a628b1de Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Thu, 17 Jul 2025 19:50:40 +0100 Subject: [PATCH 2/5] Fully implement ResponseAgent local thread support and add integration tests. --- dotnet/src/Agents/A2A/A2AAgent.cs | 4 +- dotnet/src/Agents/Abstractions/Agent.cs | 7 ++- .../src/Agents/Copilot/CopilotStudioAgent.cs | 4 +- dotnet/src/Agents/Core/ChatCompletionAgent.cs | 2 +- .../OpenAI/Internal/ResponseThreadActions.cs | 24 ++++--- .../src/Agents/OpenAI/OpenAIResponseAgent.cs | 2 +- .../OpenAIResponseAgentThreadTests.cs | 19 +++++- ...ResponseAgentWithAIContextProviderTests.cs | 6 +- .../OpenAIResponseAgentInvokeTests.cs | 32 +++++++++- ...OpenAIResponseAgentInvokeStreamingTests.cs | 27 +++++++- .../OpenAIResponseAgentFixture.cs | 62 ++++++++++++------- 11 files changed, 148 insertions(+), 41 deletions(-) diff --git a/dotnet/src/Agents/A2A/A2AAgent.cs b/dotnet/src/Agents/A2A/A2AAgent.cs index 2dabfd9f95a9..6f9876219bf6 100644 --- a/dotnet/src/Agents/A2A/A2AAgent.cs +++ b/dotnet/src/Agents/A2A/A2AAgent.cs @@ -47,7 +47,7 @@ public override async IAsyncEnumerable> In { Verify.NotNull(messages); - var agentThread = await this.EnsureThreadExistsWithMessagesAsync( + var agentThread = await this.EnsureThreadExistsWithMessagesAsync( messages, thread, () => new A2AAgentThread(this.Client), @@ -75,7 +75,7 @@ public override async IAsyncEnumerable( + var agentThread = await this.EnsureThreadExistsWithMessagesAsync( messages, thread, () => new A2AAgentThread(this.Client), diff --git a/dotnet/src/Agents/Abstractions/Agent.cs b/dotnet/src/Agents/Abstractions/Agent.cs index 19685aeec8b4..1243c62c4380 100644 --- a/dotnet/src/Agents/Abstractions/Agent.cs +++ b/dotnet/src/Agents/Abstractions/Agent.cs @@ -350,7 +350,12 @@ protected virtual async Task EnsureThreadExistsWithMessagesAsync> In } // Create a thread if needed - CopilotStudioAgentThread agentThread = await this.EnsureThreadExistsWithMessagesAsync( + CopilotStudioAgentThread agentThread = await this.EnsureThreadExistsWithMessagesAsync( messages, thread, () => new CopilotStudioAgentThread(this.Client) { Logger = this.ActiveLoggerFactory.CreateLogger() }, @@ -92,7 +92,7 @@ public override async IAsyncEnumerable( + CopilotStudioAgentThread agentThread = await this.EnsureThreadExistsWithMessagesAsync( messages, thread, () => new CopilotStudioAgentThread(this.Client) { Logger = this.ActiveLoggerFactory.CreateLogger() }, diff --git a/dotnet/src/Agents/Core/ChatCompletionAgent.cs b/dotnet/src/Agents/Core/ChatCompletionAgent.cs index 3a69a68051a4..fa6436daace4 100644 --- a/dotnet/src/Agents/Core/ChatCompletionAgent.cs +++ b/dotnet/src/Agents/Core/ChatCompletionAgent.cs @@ -95,7 +95,7 @@ public override async IAsyncEnumerable> In kernel.Plugins.AddFromAIContext(providersContext, "Tools"); #pragma warning restore SKEXP0110, SKEXP0130 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. - // Invoke Chat Completion with the updated chat history. + // Retrieve the chat history from the thread. ChatHistory chatHistory = []; await foreach (var existingMessage in retrievableAgentThread.GetMessagesAsync(cancellationToken).ConfigureAwait(false)) { diff --git a/dotnet/src/Agents/OpenAI/Internal/ResponseThreadActions.cs b/dotnet/src/Agents/OpenAI/Internal/ResponseThreadActions.cs index 2214796055f4..8957eeac0e77 100644 --- a/dotnet/src/Agents/OpenAI/Internal/ResponseThreadActions.cs +++ b/dotnet/src/Agents/OpenAI/Internal/ResponseThreadActions.cs @@ -34,7 +34,7 @@ internal static async IAsyncEnumerable InvokeAsync( if (!agent.StoreEnabled) { // Use the thread chat history - overrideHistory = [.. GetChatHistory(agentThread)]; + overrideHistory = [.. await GetChatHistoryAsync(agentThread, cancellationToken).ConfigureAwait(false)]; } var creationOptions = ResponseCreationOptionsFactory.CreateOptions(agent, agentThread, options); @@ -61,7 +61,7 @@ internal static async IAsyncEnumerable InvokeAsync( } var message = response.ToChatMessageContent(); - overrideHistory.Add(message); + history.Add(message); yield return message; // Reached maximum auto invocations @@ -108,7 +108,7 @@ await functionProcessor.InvokeFunctionCallsAsync( Role = AuthorRole.Tool, Items = items, }; - overrideHistory.Add(functionResultMessage); + history.Add(functionResultMessage); yield return functionResultMessage; } } @@ -126,7 +126,7 @@ internal static async IAsyncEnumerable InvokeStream if (!agent.StoreEnabled) { // Use the thread chat history - overrideHistory = [.. GetChatHistory(agentThread)]; + overrideHistory = [.. await GetChatHistoryAsync(agentThread, cancellationToken).ConfigureAwait(false)]; } var inputItems = overrideHistory.Select(m => m.ToResponseItem()).ToList(); @@ -159,7 +159,7 @@ internal static async IAsyncEnumerable InvokeStream case StreamingResponseCompletedUpdate completedUpdate: response = completedUpdate.Response; message = completedUpdate.Response.ToChatMessageContent(); - overrideHistory.Add(message); + history.Add(message); break; case StreamingResponseOutputItemAddedUpdate outputItemAddedUpdate: @@ -287,16 +287,22 @@ await functionProcessor.InvokeFunctionCallsAsync( InnerContent = functionCallUpdateContent, Items = [functionCallUpdateContent], }; - overrideHistory.Add(functionResultMessage); + history.Add(functionResultMessage); yield return streamingFunctionResultMessage; } } - private static ChatHistory GetChatHistory(AgentThread agentThread) + private static async Task GetChatHistoryAsync(AgentThread agentThread, CancellationToken cancellationToken) { - if (agentThread is ChatHistoryAgentThread chatHistoryAgentThread) + if (agentThread is IAgentThreadRetrievable agentThreadRetrievable) { - return chatHistoryAgentThread.ChatHistory; + ChatHistory chatHistory = []; + await foreach (var message in agentThreadRetrievable.GetMessagesAsync(cancellationToken).ConfigureAwait(false)) + { + chatHistory.Add(message); + } + + return chatHistory; } throw new InvalidOperationException("The agent thread is not a ChatHistoryAgentThread."); diff --git a/dotnet/src/Agents/OpenAI/OpenAIResponseAgent.cs b/dotnet/src/Agents/OpenAI/OpenAIResponseAgent.cs index bdc8ad512266..c77ab6c7a6f9 100644 --- a/dotnet/src/Agents/OpenAI/OpenAIResponseAgent.cs +++ b/dotnet/src/Agents/OpenAI/OpenAIResponseAgent.cs @@ -132,7 +132,7 @@ private async Task EnsureThreadExistsWithMessagesAsync(ICollection< { if (this.StoreEnabled) { - return await this.EnsureThreadExistsWithMessagesAsync(messages, thread, () => new OpenAIResponseAgentThread(this.Client), requiresThreadRetrieval: false, cancellationToken).ConfigureAwait(false); + return await this.EnsureThreadExistsWithMessagesAsync(messages, thread, () => new OpenAIResponseAgentThread(this.Client), requiresThreadRetrieval: false, cancellationToken).ConfigureAwait(false); } return await this.EnsureThreadExistsWithMessagesAsync(messages, thread, () => new ChatHistoryAgentThread(), requiresThreadRetrieval: true, cancellationToken).ConfigureAwait(false); diff --git a/dotnet/src/IntegrationTests/Agents/CommonInterfaceConformance/AgentThreadConformance/OpenAIResponseAgentThreadTests.cs b/dotnet/src/IntegrationTests/Agents/CommonInterfaceConformance/AgentThreadConformance/OpenAIResponseAgentThreadTests.cs index 70d36b9411a1..89ae7da8407f 100644 --- a/dotnet/src/IntegrationTests/Agents/CommonInterfaceConformance/AgentThreadConformance/OpenAIResponseAgentThreadTests.cs +++ b/dotnet/src/IntegrationTests/Agents/CommonInterfaceConformance/AgentThreadConformance/OpenAIResponseAgentThreadTests.cs @@ -5,7 +5,7 @@ namespace SemanticKernel.IntegrationTests.Agents.CommonInterfaceConformance.AgentThreadConformance; -public class OpenAIResponseAgentThreadTests() : AgentThreadTests(() => new OpenAIResponseAgentFixture()) +public class OpenAIResponseAgentStoreEnabledThreadTests() : AgentThreadTests(() => new OpenAIResponseAgentFixture(true)) { [Fact] public override Task OnNewMessageWithServiceFailureThrowsAgentOperationExceptionAsync() @@ -21,3 +21,20 @@ public override Task UsingThreadBeforeCreateCreatesAsync() return Task.CompletedTask; } } + +public class OpenAIResponseAgentStoreDisabledThreadTests() : AgentThreadTests(() => new OpenAIResponseAgentFixture(false)) +{ + [Fact] + public override Task DeleteThreadWithServiceFailureThrowsAgentOperationExceptionAsync() + { + // Test not applicable since there is no service to fail. + return Task.CompletedTask; + } + + [Fact] + public override Task OnNewMessageWithServiceFailureThrowsAgentOperationExceptionAsync() + { + // Test not applicable since there is no service to fail. + return Task.CompletedTask; + } +} diff --git a/dotnet/src/IntegrationTests/Agents/CommonInterfaceConformance/AgentWithAIContextProviderConformance/OpenAIResponseAgentWithAIContextProviderTests.cs b/dotnet/src/IntegrationTests/Agents/CommonInterfaceConformance/AgentWithAIContextProviderConformance/OpenAIResponseAgentWithAIContextProviderTests.cs index 96f3d8d0e265..438095c1778e 100644 --- a/dotnet/src/IntegrationTests/Agents/CommonInterfaceConformance/AgentWithAIContextProviderConformance/OpenAIResponseAgentWithAIContextProviderTests.cs +++ b/dotnet/src/IntegrationTests/Agents/CommonInterfaceConformance/AgentWithAIContextProviderConformance/OpenAIResponseAgentWithAIContextProviderTests.cs @@ -2,6 +2,10 @@ namespace SemanticKernel.IntegrationTests.Agents.CommonInterfaceConformance.AgentWithStatePartConformance; -public class OpenAIResponseAgentWithAIContextProviderTests() : AgentWithAIContextProviderTests(() => new OpenAIResponseAgentFixture()) +public class OpenAIResponseAgentStoreEnabledWithAIContextProviderTests() : AgentWithAIContextProviderTests(() => new OpenAIResponseAgentFixture(true)) +{ +} + +public class OpenAIResponseAgentStoreDisabledWithAIContextProviderTests() : AgentWithAIContextProviderTests(() => new OpenAIResponseAgentFixture(false)) { } diff --git a/dotnet/src/IntegrationTests/Agents/CommonInterfaceConformance/InvokeConformance/OpenAIResponseAgentInvokeTests.cs b/dotnet/src/IntegrationTests/Agents/CommonInterfaceConformance/InvokeConformance/OpenAIResponseAgentInvokeTests.cs index cc51f7c86395..e8f8ef57eb30 100644 --- a/dotnet/src/IntegrationTests/Agents/CommonInterfaceConformance/InvokeConformance/OpenAIResponseAgentInvokeTests.cs +++ b/dotnet/src/IntegrationTests/Agents/CommonInterfaceConformance/InvokeConformance/OpenAIResponseAgentInvokeTests.cs @@ -7,7 +7,7 @@ namespace SemanticKernel.IntegrationTests.Agents.CommonInterfaceConformance.InvokeConformance; -public class OpenAIResponseAgentInvokeTests() : InvokeTests(() => new OpenAIResponseAgentFixture()) +public class OpenAIResponseAgentStoreEnabledInvokeTests() : InvokeTests(() => new OpenAIResponseAgentFixture(true)) { [Fact(Skip = $"{nameof(OpenAIResponseAgent)} excludes the final response from the remote history.")] public override Task ConversationMaintainsHistoryAsync() @@ -30,3 +30,33 @@ public override Task InvokeWithPluginNotifiesForAllMessagesAsync() return base.InvokeWithPluginNotifiesForAllMessagesAsync(); } } + +public class OpenAIResponseAgentStoreDisabledInvokeTests() : InvokeTests(() => new OpenAIResponseAgentFixture(false)) +{ + [Fact(Skip = $"{nameof(OpenAIResponseAgent)} excludes the final response from the remote history.")] + public override Task ConversationMaintainsHistoryAsync() + { + return base.ConversationMaintainsHistoryAsync(); + } + + /// + /// must be invoked with a message. + /// + [Fact] + public override Task InvokeWithoutMessageCreatesThreadAsync() + { + return Assert.ThrowsAsync(() => base.InvokeWithoutMessageCreatesThreadAsync()); + } + + [Fact(Skip = $"{nameof(OpenAIResponseAgent)} fails to notify for all messages - Issue #12468")] + public override Task InvokeWithPluginNotifiesForAllMessagesAsync() + { + return base.InvokeWithPluginNotifiesForAllMessagesAsync(); + } + + [Fact(Skip = $"{nameof(OpenAIResponseAgent)} does not current support tool messages provided as input, causing local threads with tool calls to fail")] + public override Task MultiStepInvokeWithPluginAndArgOverridesAsync() + { + return base.MultiStepInvokeWithPluginAndArgOverridesAsync(); + } +} diff --git a/dotnet/src/IntegrationTests/Agents/CommonInterfaceConformance/InvokeStreamingConformance/OpenAIResponseAgentInvokeStreamingTests.cs b/dotnet/src/IntegrationTests/Agents/CommonInterfaceConformance/InvokeStreamingConformance/OpenAIResponseAgentInvokeStreamingTests.cs index a8d7e184c63f..fd24ed925905 100644 --- a/dotnet/src/IntegrationTests/Agents/CommonInterfaceConformance/InvokeStreamingConformance/OpenAIResponseAgentInvokeStreamingTests.cs +++ b/dotnet/src/IntegrationTests/Agents/CommonInterfaceConformance/InvokeStreamingConformance/OpenAIResponseAgentInvokeStreamingTests.cs @@ -8,7 +8,7 @@ namespace SemanticKernel.IntegrationTests.Agents.CommonInterfaceConformance.InvokeStreamingConformance; [Collection("Sequential")] -public class OpenAIResponseAgentInvokeStreamingTests() : InvokeStreamingTests(() => new OpenAIResponseAgentFixture()) +public class OpenAIResponseAgentStoreEnabledInvokeStreamingTests() : InvokeStreamingTests(() => new OpenAIResponseAgentFixture(true)) { [Fact(Skip = $"{nameof(OpenAIResponseAgent)} excludes the final response from the remote history.")] public override Task ConversationMaintainsHistoryAsync() @@ -25,3 +25,28 @@ public override Task InvokeStreamingAsyncWithoutMessageCreatesThreadAsync() return Assert.ThrowsAsync(() => base.InvokeStreamingAsyncWithoutMessageCreatesThreadAsync()); } } + +[Collection("Sequential")] +public class OpenAIResponseAgentStoreDisabledInvokeStreamingTests() : InvokeStreamingTests(() => new OpenAIResponseAgentFixture(false)) +{ + [Fact(Skip = $"{nameof(OpenAIResponseAgent)} excludes the final response from the remote history.")] + public override Task ConversationMaintainsHistoryAsync() + { + return base.ConversationMaintainsHistoryAsync(); + } + + /// + /// must be invoked with a message. + /// + [Fact] + public override Task InvokeStreamingAsyncWithoutMessageCreatesThreadAsync() + { + return Assert.ThrowsAsync(() => base.InvokeStreamingAsyncWithoutMessageCreatesThreadAsync()); + } + + [Fact(Skip = $"{nameof(OpenAIResponseAgent)} does not current support tool messages provided as input, causing local threads with tool calls to fail")] + public override Task MultiStepInvokeStreamingAsyncWithPluginAndArgOverridesAsync() + { + return base.MultiStepInvokeStreamingAsyncWithPluginAndArgOverridesAsync(); + } +} diff --git a/dotnet/src/IntegrationTests/Agents/CommonInterfaceConformance/OpenAIResponseAgentFixture.cs b/dotnet/src/IntegrationTests/Agents/CommonInterfaceConformance/OpenAIResponseAgentFixture.cs index 5a14cd765374..9477ccd61cb0 100644 --- a/dotnet/src/IntegrationTests/Agents/CommonInterfaceConformance/OpenAIResponseAgentFixture.cs +++ b/dotnet/src/IntegrationTests/Agents/CommonInterfaceConformance/OpenAIResponseAgentFixture.cs @@ -1,4 +1,5 @@ // Copyright (c) Microsoft. All rights reserved. +using System; using System.ClientModel; using System.Threading.Tasks; using Microsoft.Extensions.Configuration; @@ -15,7 +16,7 @@ namespace SemanticKernel.IntegrationTests.Agents.CommonInterfaceConformance; /// /// Contains setup and teardown for the tests. /// -public class OpenAIResponseAgentFixture : AgentFixture +public class OpenAIResponseAgentFixture(bool storeEnabled) : AgentFixture { private readonly IConfigurationRoot _configuration = new ConfigurationBuilder() .AddJsonFile(path: "testsettings.json", optional: true, reloadOnChange: true) @@ -26,7 +27,7 @@ public class OpenAIResponseAgentFixture : AgentFixture private OpenAIResponseClient? _responseClient; private OpenAIResponseAgent? _agent; - private OpenAIResponseAgentThread? _thread; + private AgentThread? _thread; private OpenAIResponseAgentThread? _createdThread; private OpenAIResponseAgentThread? _serviceFailingAgentThread; private OpenAIResponseAgentThread? _createdServiceFailingAgentThread; @@ -45,47 +46,66 @@ public class OpenAIResponseAgentFixture : AgentFixture public override AgentThread GetNewThread() { - return new OpenAIResponseAgentThread(this._responseClient!); + return storeEnabled ? new OpenAIResponseAgentThread(this._responseClient!) : new ChatHistoryAgentThread(); } public override async Task GetChatHistory() { var chatHistory = new ChatHistory(); - await foreach (var existingMessage in this._thread!.GetMessagesAsync().ConfigureAwait(false)) + + if (this._thread is ChatHistoryAgentThread chatHistoryAgentThread) + { + await foreach (var existingMessage in chatHistoryAgentThread.GetMessagesAsync().ConfigureAwait(false)) + { + chatHistory.Add(existingMessage); + } + return chatHistory; + } + + if (this._thread is not OpenAIResponseAgentThread openAIResponseAgentThread) + { + throw new InvalidOperationException("The thread is not an OpenAIResponseAgentThread or a ChatHistoryAgentThread."); + } + + await foreach (var existingMessage in openAIResponseAgentThread.GetMessagesAsync().ConfigureAwait(false)) { chatHistory.Add(existingMessage); } + return chatHistory; } public override async Task DisposeAsync() { - if (this._thread!.Id is not null) + if (storeEnabled) { - try - { - await this._responseClient!.DeleteResponseAsync(this._thread!.Id); - } - catch (ClientResultException ex) when (ex.Status == 404) + if (this._thread!.Id is not null) { + try + { + await this._responseClient!.DeleteResponseAsync(this._thread!.Id); + } + catch (ClientResultException ex) when (ex.Status == 404) + { + } } - } - if (this._createdThread!.Id is not null) - { - try - { - await this._responseClient!.DeleteResponseAsync(this._createdThread!.Id); - } - catch (ClientResultException ex) when (ex.Status == 404) + if (this._createdThread!.Id is not null) { + try + { + await this._responseClient!.DeleteResponseAsync(this._createdThread!.Id); + } + catch (ClientResultException ex) when (ex.Status == 404) + { + } } } } public override Task DeleteThread(AgentThread thread) { - return this._responseClient!.DeleteResponseAsync(thread.Id); + return storeEnabled ? this._responseClient!.DeleteResponseAsync(thread.Id) : Task.CompletedTask; } public override async Task InitializeAsync() @@ -101,10 +121,10 @@ public override async Task InitializeAsync() { Name = "HelpfulAssistant", Instructions = "You are a helpful assistant.", - StoreEnabled = true, + StoreEnabled = storeEnabled, Kernel = kernel }; - this._thread = new OpenAIResponseAgentThread(this._responseClient); + this._thread = storeEnabled ? new OpenAIResponseAgentThread(this._responseClient) : new ChatHistoryAgentThread(); var response = await this._responseClient.CreateResponseAsync([ResponseItem.CreateUserMessageItem("Hello")]); this._createdThread = new OpenAIResponseAgentThread(this._responseClient, response.Value.Id); From 16cc193eec33933b6674820c9dfd89334d48fa27 Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Fri, 18 Jul 2025 17:40:07 +0100 Subject: [PATCH 3/5] Remove duplicate experimental --- dotnet/src/Agents/Abstractions/IAgentThreadRetrievable.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/dotnet/src/Agents/Abstractions/IAgentThreadRetrievable.cs b/dotnet/src/Agents/Abstractions/IAgentThreadRetrievable.cs index 978e9359b1b9..5f2cbdbf8a05 100644 --- a/dotnet/src/Agents/Abstractions/IAgentThreadRetrievable.cs +++ b/dotnet/src/Agents/Abstractions/IAgentThreadRetrievable.cs @@ -36,6 +36,5 @@ public interface IAgentThreadRetrievable /// The to monitor for cancellation requests. The default is . /// The messages in the thread. /// The thread has been deleted. - [Experimental("SKEXP0110")] IAsyncEnumerable GetMessagesAsync(CancellationToken cancellationToken = default); } From ddd0775665c6cd48d3302a0ecb0bd37d86ea9c57 Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Mon, 21 Jul 2025 14:35:32 +0100 Subject: [PATCH 4/5] Rename interface and update xml docs. --- dotnet/src/Agents/Abstractions/Agent.cs | 6 +++--- ...able.cs => IAgentThreadMessageProvider.cs} | 20 +++++++++++++------ dotnet/src/Agents/Core/ChatCompletionAgent.cs | 4 ++-- .../src/Agents/Core/ChatHistoryAgentThread.cs | 2 +- .../OpenAI/Internal/ResponseThreadActions.cs | 2 +- 5 files changed, 21 insertions(+), 13 deletions(-) rename dotnet/src/Agents/Abstractions/{IAgentThreadRetrievable.cs => IAgentThreadMessageProvider.cs} (62%) diff --git a/dotnet/src/Agents/Abstractions/Agent.cs b/dotnet/src/Agents/Abstractions/Agent.cs index 1243c62c4380..3bdc726af99f 100644 --- a/dotnet/src/Agents/Abstractions/Agent.cs +++ b/dotnet/src/Agents/Abstractions/Agent.cs @@ -334,7 +334,7 @@ public abstract IAsyncEnumerable> /// The messages to add to the thread once it is setup. /// The thread to create if it's null, validate it's type if not null, and start if it is not active. /// A callback to use to construct the thread if it's null. - /// true if the thread must implement to allow message retrieval. + /// true if the thread must implement to allow message retrieval. /// The to monitor for cancellation requests. The default is . /// An async task that completes once all update are complete. protected virtual async Task EnsureThreadExistsWithMessagesAsync( @@ -350,9 +350,9 @@ protected virtual async Task EnsureThreadExistsWithMessagesAsync -/// Interface for any Semantic Kernel agent threads that allow the messages -/// contained in them to be passed to an agent. +/// Interface for any Semantic Kernel agent thread that allow the messages +/// contained in it to be passed to an agent. /// /// /// /// types that implement this interface can -/// be used with Agents that do not maintain a server-side chat history -/// and require the entire set of messages, that are needed to generate -/// a response, to be provided to the agent at invocation time. +/// be used with Agents that do not maintain a server-side chat history, e.g. ChatCompletionAgent. +/// These agents are typically implmented using simple LLMs and therefore +/// require the entire chat history to be provided to the LLM for each invocation. +/// +/// +/// This is in contrast to agents that maintain a server-side chat history, e.g. AzureAIAgentThread, +/// where the chat history is stored on the server and managed by the agent service. /// /// /// The set of messages returned may be truncated or processed /// by the as needed before passed to the /// agent to achieve a scalable and performant solution. /// +/// +/// This interface can be used to implement custom agent threads, that store messages +/// in a database or 3rd party service, instead of in-memory like done by ChatHistoryAgentThread. +/// /// [Experimental("SKEXP0110")] -public interface IAgentThreadRetrievable +public interface IAgentThreadMessageProvider { /// /// Asynchronously retrieves all messages to be used for the agent invocation. diff --git a/dotnet/src/Agents/Core/ChatCompletionAgent.cs b/dotnet/src/Agents/Core/ChatCompletionAgent.cs index fa6436daace4..d3ead9d60303 100644 --- a/dotnet/src/Agents/Core/ChatCompletionAgent.cs +++ b/dotnet/src/Agents/Core/ChatCompletionAgent.cs @@ -74,7 +74,7 @@ public override async IAsyncEnumerable> In () => new ChatHistoryAgentThread(), requiresThreadRetrieval: true, cancellationToken).ConfigureAwait(false); - var retrievableAgentThread = (IAgentThreadRetrievable)safeAgentThread; + var retrievableAgentThread = (IAgentThreadMessageProvider)safeAgentThread; Kernel kernel = this.GetKernel(options); #pragma warning disable SKEXP0110, SKEXP0130 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. @@ -185,7 +185,7 @@ public override async IAsyncEnumerable new ChatHistoryAgentThread(), requiresThreadRetrieval: true, cancellationToken).ConfigureAwait(false); - var retrievableAgentThread = (IAgentThreadRetrievable)safeAgentThread; + var retrievableAgentThread = (IAgentThreadMessageProvider)safeAgentThread; Kernel kernel = this.GetKernel(options); #pragma warning disable SKEXP0110, SKEXP0130 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. diff --git a/dotnet/src/Agents/Core/ChatHistoryAgentThread.cs b/dotnet/src/Agents/Core/ChatHistoryAgentThread.cs index ab1b0b29bf31..d84637f68c44 100644 --- a/dotnet/src/Agents/Core/ChatHistoryAgentThread.cs +++ b/dotnet/src/Agents/Core/ChatHistoryAgentThread.cs @@ -13,7 +13,7 @@ namespace Microsoft.SemanticKernel.Agents; /// /// Represents a conversation thread based on an instance of that is managed inside this class. /// -public sealed class ChatHistoryAgentThread : AgentThread, IAgentThreadRetrievable +public sealed class ChatHistoryAgentThread : AgentThread, IAgentThreadMessageProvider { private readonly ChatHistory _chatHistory = new(); diff --git a/dotnet/src/Agents/OpenAI/Internal/ResponseThreadActions.cs b/dotnet/src/Agents/OpenAI/Internal/ResponseThreadActions.cs index 8957eeac0e77..1f55f05b6338 100644 --- a/dotnet/src/Agents/OpenAI/Internal/ResponseThreadActions.cs +++ b/dotnet/src/Agents/OpenAI/Internal/ResponseThreadActions.cs @@ -294,7 +294,7 @@ await functionProcessor.InvokeFunctionCallsAsync( private static async Task GetChatHistoryAsync(AgentThread agentThread, CancellationToken cancellationToken) { - if (agentThread is IAgentThreadRetrievable agentThreadRetrievable) + if (agentThread is IAgentThreadMessageProvider agentThreadRetrievable) { ChatHistory chatHistory = []; await foreach (var message in agentThreadRetrievable.GetMessagesAsync(cancellationToken).ConfigureAwait(false)) From 6e803c14a025db137f513934b692c13919b8fbab Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Mon, 21 Jul 2025 14:37:12 +0100 Subject: [PATCH 5/5] Fix typo --- dotnet/src/Agents/Abstractions/IAgentThreadMessageProvider.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotnet/src/Agents/Abstractions/IAgentThreadMessageProvider.cs b/dotnet/src/Agents/Abstractions/IAgentThreadMessageProvider.cs index 01950f34ab76..c19bf5910e63 100644 --- a/dotnet/src/Agents/Abstractions/IAgentThreadMessageProvider.cs +++ b/dotnet/src/Agents/Abstractions/IAgentThreadMessageProvider.cs @@ -15,7 +15,7 @@ namespace Microsoft.SemanticKernel.Agents; /// /// types that implement this interface can /// be used with Agents that do not maintain a server-side chat history, e.g. ChatCompletionAgent. -/// These agents are typically implmented using simple LLMs and therefore +/// These agents are typically implemented using simple LLMs and therefore /// require the entire chat history to be provided to the LLM for each invocation. /// ///