diff --git a/dotnet/src/Agents/A2A/A2AAgent.cs b/dotnet/src/Agents/A2A/A2AAgent.cs index c831a9a89f79..6f9876219bf6 100644 --- a/dotnet/src/Agents/A2A/A2AAgent.cs +++ b/dotnet/src/Agents/A2A/A2AAgent.cs @@ -51,6 +51,7 @@ public override async IAsyncEnumerable> In messages, thread, () => new A2AAgentThread(this.Client), + requiresThreadRetrieval: false, cancellationToken).ConfigureAwait(false); // Invoke the agent. @@ -78,6 +79,7 @@ public override async IAsyncEnumerable new A2AAgentThread(this.Client), + requiresThreadRetrieval: false, cancellationToken).ConfigureAwait(false); // Invoke the agent. diff --git a/dotnet/src/Agents/Abstractions/Agent.cs b/dotnet/src/Agents/Abstractions/Agent.cs index 0413475be286..3bdc726af99f 100644 --- a/dotnet/src/Agents/Abstractions/Agent.cs +++ b/dotnet/src/Agents/Abstractions/Agent.cs @@ -326,6 +326,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. /// @@ -333,13 +334,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 { @@ -348,6 +350,11 @@ protected virtual async Task EnsureThreadExistsWithMessagesAsync EnsureThreadExistsWithMessagesAsync /// Notfiy the given thread that a new message is available. diff --git a/dotnet/src/Agents/Abstractions/IAgentThreadMessageProvider.cs b/dotnet/src/Agents/Abstractions/IAgentThreadMessageProvider.cs new file mode 100644 index 000000000000..c19bf5910e63 --- /dev/null +++ b/dotnet/src/Agents/Abstractions/IAgentThreadMessageProvider.cs @@ -0,0 +1,48 @@ +// 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 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, e.g. ChatCompletionAgent. +/// These agents are typically implemented 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 IAgentThreadMessageProvider +{ + /// + /// 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. + IAsyncEnumerable GetMessagesAsync(CancellationToken cancellationToken = default); +} diff --git a/dotnet/src/Agents/AzureAI/AzureAIAgent.cs b/dotnet/src/Agents/AzureAI/AzureAIAgent.cs index 1b252b84eddc..74e046e01ed3 100644 --- a/dotnet/src/Agents/AzureAI/AzureAIAgent.cs +++ b/dotnet/src/Agents/AzureAI/AzureAIAgent.cs @@ -135,6 +135,7 @@ public async IAsyncEnumerable> InvokeAsync messages, thread, () => new AzureAIAgentThread(this.Client), + requiresThreadRetrieval: false, cancellationToken).ConfigureAwait(false); Kernel kernel = this.GetKernel(options); @@ -238,6 +239,7 @@ public async IAsyncEnumerable> In messages, thread, () => new AzureAIAgentThread(this.Client), + requiresThreadRetrieval: false, cancellationToken).ConfigureAwait(false); Kernel kernel = this.GetKernel(options); diff --git a/dotnet/src/Agents/Bedrock/BedrockAgent.cs b/dotnet/src/Agents/Bedrock/BedrockAgent.cs index 815ec1a859f3..2af9f334c29a 100644 --- a/dotnet/src/Agents/Bedrock/BedrockAgent.cs +++ b/dotnet/src/Agents/Bedrock/BedrockAgent.cs @@ -118,6 +118,7 @@ public override async IAsyncEnumerable> In messages, thread, () => new BedrockAgentThread(this.RuntimeClient), + requiresThreadRetrieval: false, cancellationToken).ConfigureAwait(false); // Get the context contributions from the AIContextProviders. @@ -200,6 +201,7 @@ public async IAsyncEnumerable> InvokeAsync [], thread, () => new BedrockAgentThread(this.RuntimeClient), + requiresThreadRetrieval: false, cancellationToken).ConfigureAwait(false); // Configure the agent request with the provided options @@ -259,6 +261,7 @@ public override async IAsyncEnumerable new BedrockAgentThread(this.RuntimeClient), + requiresThreadRetrieval: false, cancellationToken).ConfigureAwait(false); // Get the context contributions from the AIContextProviders. @@ -342,6 +345,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/Copilot/CopilotStudioAgent.cs b/dotnet/src/Agents/Copilot/CopilotStudioAgent.cs index f9424e72a7b9..a120e94d6d2a 100644 --- a/dotnet/src/Agents/Copilot/CopilotStudioAgent.cs +++ b/dotnet/src/Agents/Copilot/CopilotStudioAgent.cs @@ -57,6 +57,7 @@ public override async IAsyncEnumerable> In messages, thread, () => new CopilotStudioAgentThread(this.Client) { Logger = this.ActiveLoggerFactory.CreateLogger() }, + requiresThreadRetrieval: false, cancellationToken).ConfigureAwait(false); // Invoke the agent @@ -95,6 +96,7 @@ public override async IAsyncEnumerable new CopilotStudioAgentThread(this.Client) { Logger = this.ActiveLoggerFactory.CreateLogger() }, + requiresThreadRetrieval: false, cancellationToken).ConfigureAwait(false); // Invoke the agent diff --git a/dotnet/src/Agents/Core/ChatCompletionAgent.cs b/dotnet/src/Agents/Core/ChatCompletionAgent.cs index 307009fe5099..d3ead9d60303 100644 --- a/dotnet/src/Agents/Core/ChatCompletionAgent.cs +++ b/dotnet/src/Agents/Core/ChatCompletionAgent.cs @@ -67,11 +67,14 @@ public override async IAsyncEnumerable> In { Verify.NotNull(messages); - ChatHistoryAgentThread chatHistoryAgentThread = await this.EnsureThreadExistsWithMessagesAsync( + // Ensure the thread exists, is updated with our new messages, and is retrievable. + AgentThread safeAgentThread = await this.EnsureThreadExistsWithMessagesAsync( messages, thread, () => new ChatHistoryAgentThread(), + requiresThreadRetrieval: true, cancellationToken).ConfigureAwait(false); + 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. @@ -81,7 +84,7 @@ public override async IAsyncEnumerable> In } // Get the context contributions from the AIContextProviders. - AIContext providersContext = await chatHistoryAgentThread.AIContextProviders.ModelInvokingAsync(messages, cancellationToken).ConfigureAwait(false); + AIContext providersContext = await safeAgentThread.AIContextProviders.ModelInvokingAsync(messages, cancellationToken).ConfigureAwait(false); // Check for compatibility AIContextProviders and the UseImmutableKernel setting. if (providersContext.AIFunctions is { Count: > 0 } && !this.UseImmutableKernel) @@ -92,18 +95,20 @@ 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 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?.OnIntermediateMessage is not null) { await options.OnIntermediateMessage(m).ConfigureAwait(false); @@ -132,7 +137,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?.OnIntermediateMessage is not null) { @@ -140,7 +145,7 @@ public override async IAsyncEnumerable> In } } - yield return new(result, chatHistoryAgentThread); + yield return new(result, safeAgentThread); } } @@ -173,11 +178,14 @@ public override async IAsyncEnumerable( messages, thread, () => new ChatHistoryAgentThread(), + requiresThreadRetrieval: true, cancellationToken).ConfigureAwait(false); + 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. @@ -187,7 +195,7 @@ public override async IAsyncEnumerable 0 } && !this.UseImmutableKernel) @@ -198,19 +206,21 @@ public override async IAsyncEnumerable { - await this.NotifyThreadOfNewMessage(chatHistoryAgentThread, m, cancellationToken).ConfigureAwait(false); + await this.NotifyThreadOfNewMessage(safeAgentThread, m, cancellationToken).ConfigureAwait(false); if (options?.OnIntermediateMessage is not null) { await options.OnIntermediateMessage(m).ConfigureAwait(false); @@ -223,7 +233,7 @@ public override async IAsyncEnumerable /// Represents a conversation thread based on an instance of that is managed inside this class. /// -public sealed class ChatHistoryAgentThread : AgentThread +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 2214796055f4..1f55f05b6338 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 IAgentThreadMessageProvider 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/OpenAIAssistantAgent.cs b/dotnet/src/Agents/OpenAI/OpenAIAssistantAgent.cs index 11f813fd3267..2216547bad25 100644 --- a/dotnet/src/Agents/OpenAI/OpenAIAssistantAgent.cs +++ b/dotnet/src/Agents/OpenAI/OpenAIAssistantAgent.cs @@ -129,6 +129,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 @@ -236,6 +237,7 @@ public async IAsyncEnumerable> In messages, thread, () => new OpenAIAssistantAgentThread(this.Client), + requiresThreadRetrieval: false, cancellationToken).ConfigureAwait(false); Kernel kernel = this.GetKernel(options); diff --git a/dotnet/src/Agents/OpenAI/OpenAIResponseAgent.cs b/dotnet/src/Agents/OpenAI/OpenAIResponseAgent.cs index 0e1f84d610df..c77ab6c7a6f9 100644 --- a/dotnet/src/Agents/OpenAI/OpenAIResponseAgent.cs +++ b/dotnet/src/Agents/OpenAI/OpenAIResponseAgent.cs @@ -132,10 +132,10 @@ private async Task EnsureThreadExistsWithMessagesAsync(ICollection< { if (this.StoreEnabled) { - return await this.EnsureThreadExistsWithMessagesAsync(messages, thread, () => new OpenAIResponseAgentThread(this.Client), 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(), cancellationToken).ConfigureAwait(false); + return await this.EnsureThreadExistsWithMessagesAsync(messages, thread, () => new ChatHistoryAgentThread(), requiresThreadRetrieval: true, cancellationToken).ConfigureAwait(false); } private async Task FinalizeInvokeOptionsAsync(ICollection messages, AgentInvokeOptions? options, AgentThread agentThread, CancellationToken cancellationToken) 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);