diff --git a/dotnet/samples/Concepts/Agents/ChatCompletion_ServiceSelection.cs b/dotnet/samples/Concepts/Agents/ChatCompletion_ServiceSelection.cs index 46ea8dea2246..4a2663a1f5f0 100644 --- a/dotnet/samples/Concepts/Agents/ChatCompletion_ServiceSelection.cs +++ b/dotnet/samples/Concepts/Agents/ChatCompletion_ServiceSelection.cs @@ -8,7 +8,7 @@ namespace Agents; /// /// Demonstrate service selection for through setting service-id /// on and also providing override -/// when calling +/// when calling /// public class ChatCompletion_ServiceSelection(ITestOutputHelper output) : BaseAgentsTest(output) { diff --git a/dotnet/src/Agents/Abstractions/Agent.cs b/dotnet/src/Agents/Abstractions/Agent.cs index 383b5df27385..a742d7ce6767 100644 --- a/dotnet/src/Agents/Abstractions/Agent.cs +++ b/dotnet/src/Agents/Abstractions/Agent.cs @@ -43,6 +43,48 @@ public abstract class Agent /// public ILoggerFactory? LoggerFactory { get; init; } + /// + /// Invoke the agent with the provided message and arguments. + /// + /// The message to pass to the agent. + /// The conversation thread to continue with this invocation. If not provided, creates a new thread. + /// Optional arguments to pass to the agents's invocation, including any . + /// The containing services, plugins, and other state for use by the agent. + /// Optional parameters for agent invocation. + /// The to monitor for cancellation requests. The default is . + /// An async list of response items that each contain a and an . + /// + /// To continue this thread in the future, use an returned in one of the response items. + /// + public abstract IAsyncEnumerable> InvokeAsync( + ChatMessageContent message, + AgentThread? thread = null, + KernelArguments? arguments = null, + Kernel? kernel = null, + AgentInvokeOptions? options = null, + CancellationToken cancellationToken = default); + + /// + /// Invoke the agent with the provided message and arguments. + /// + /// The message to pass to the agent. + /// The conversation thread to continue with this invocation. If not provided, creates a new thread. + /// Optional arguments to pass to the agents's invocation, including any . + /// The containing services, plugins, and other state for use by the agent. + /// Optional parameters for agent invocation. + /// The to monitor for cancellation requests. The default is . + /// An async list of response items that each contain a and an . + /// + /// To continue this thread in the future, use an returned in one of the response items. + /// + public abstract IAsyncEnumerable> InvokeStreamingAsync( + ChatMessageContent message, + AgentThread? thread = null, + KernelArguments? arguments = null, + Kernel? kernel = null, + AgentInvokeOptions? options = null, + CancellationToken cancellationToken = default); + /// /// The associated with this . /// @@ -96,4 +138,39 @@ public abstract class Agent protected internal abstract Task RestoreChannelAsync(string channelState, CancellationToken cancellationToken); private ILogger? _logger; + + /// + /// Ensures that the thread exists, is of the expected type, and is active, plus adds the provided message to the thread. + /// + /// The expected type of the thead. + /// The message 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. + /// The to monitor for cancellation requests. The default is . + /// An async task that completes once all update are complete. + /// + protected async Task EnsureThreadExistsWithMessageAsync( + ChatMessageContent message, + AgentThread? thread, + Func constructThread, + CancellationToken cancellationToken) + where TThreadType : AgentThread + { + if (thread is null) + { + thread = constructThread(); + } + + if (thread is not TThreadType concreteThreadType) + { + throw new KernelException($"{this.GetType().Name} currently only supports agent threads of type {nameof(TThreadType)}."); + } + + await thread.CreateAsync(cancellationToken).ConfigureAwait(false); + + // Notify the thread that a new message is available. + await thread.OnNewMessageAsync(message, cancellationToken).ConfigureAwait(false); + + return concreteThreadType; + } } diff --git a/dotnet/src/Agents/Abstractions/AgentInvokeOptions.cs b/dotnet/src/Agents/Abstractions/AgentInvokeOptions.cs new file mode 100644 index 000000000000..e93045a5544f --- /dev/null +++ b/dotnet/src/Agents/Abstractions/AgentInvokeOptions.cs @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.SemanticKernel.Agents; + +/// +/// Optional parameters for agent invocation. +/// +public class AgentInvokeOptions +{ + /// + /// Gets or sets any instructions, in addition to those that were provided to the agent + /// initially, that need to be added to the prompt for this invocation only. + /// + public string AdditionalInstructions { get; init; } = string.Empty; +} diff --git a/dotnet/src/Agents/Abstractions/AgentResponseItem.cs b/dotnet/src/Agents/Abstractions/AgentResponseItem.cs new file mode 100644 index 000000000000..e6fca3c1438d --- /dev/null +++ b/dotnet/src/Agents/Abstractions/AgentResponseItem.cs @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.SemanticKernel.Agents; + +/// +/// Container class that holds a or and an . +/// +public class AgentResponseItem +{ + private readonly TMessage _message; + private readonly AgentThread _thread; + + /// + /// Initializes a new instance of the class. + /// + /// The chat message content. + /// The conversation thread associated with the response. + public AgentResponseItem(TMessage message, AgentThread thread) + { + Verify.NotNull(message); + Verify.NotNull(thread); + + this._message = message; + this._thread = thread; + } + + /// + /// Gets the chat message content. + /// + public TMessage Message => this._message; + + /// + /// Gets the conversation thread associated with the response. + /// + public AgentThread Thread => this._thread; +} diff --git a/dotnet/src/Agents/Abstractions/AgentThread.cs b/dotnet/src/Agents/Abstractions/AgentThread.cs new file mode 100644 index 000000000000..3fc4a6bcd94a --- /dev/null +++ b/dotnet/src/Agents/Abstractions/AgentThread.cs @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.SemanticKernel.Agents; + +/// +/// Base abstraction for all Semantic Kernel agent threads. +/// A thread represents a specific conversation with an agent. +/// +/// +/// This class is used to manage the lifecycle of an agent thread. +/// The thread can be not-start, started or ended. +/// +public abstract class AgentThread +{ + /// + /// Gets the id of the current thread. + /// + public abstract string? Id { get; } + + /// + /// Creates the thread and returns the thread id. + /// + /// The to monitor for cancellation requests. The default is . + /// The id of the new thread. + public abstract Task CreateAsync(CancellationToken cancellationToken = default); + + /// + /// Deletes the current thread. + /// + /// The to monitor for cancellation requests. The default is . + /// A task that completes when the thread has been ended. + public abstract Task DeleteAsync(CancellationToken cancellationToken = default); + + /// + /// This method is called when a new message has been contributed to the chat by any participant. + /// + /// + /// Inheritors can use this method to update their context based on the new message. + /// + /// The new message. + /// The to monitor for cancellation requests. The default is . + /// A task that completes when the context has been updated. + public abstract Task OnNewMessageAsync(ChatMessageContent newMessage, CancellationToken cancellationToken = default); +} diff --git a/dotnet/src/Agents/Abstractions/AggregatorAgent.cs b/dotnet/src/Agents/Abstractions/AggregatorAgent.cs index 8cde6b5a9001..e71b7ff03f1a 100644 --- a/dotnet/src/Agents/Abstractions/AggregatorAgent.cs +++ b/dotnet/src/Agents/Abstractions/AggregatorAgent.cs @@ -44,6 +44,32 @@ public sealed class AggregatorAgent(Func chatProvider) : Agent /// public AggregatorMode Mode { get; init; } = AggregatorMode.Flat; + /// + public override IAsyncEnumerable> InvokeAsync( + ChatMessageContent message, + AgentThread? thread = null, + KernelArguments? arguments = null, + Kernel? kernel = null, + AgentInvokeOptions? options = null, + CancellationToken cancellationToken = default) + { + // TODO: Need to determine the correct approach here. + throw new NotImplementedException(); + } + + /// + public override IAsyncEnumerable> InvokeStreamingAsync( + ChatMessageContent message, + AgentThread? thread = null, + KernelArguments? arguments = null, + Kernel? kernel = null, + AgentInvokeOptions? options = null, + CancellationToken cancellationToken = default) + { + // TODO: Need to determine the correct approach here. + throw new NotImplementedException(); + } + /// /// /// Different instances will never share the same channel. diff --git a/dotnet/src/Agents/AzureAI/AzureAIAgent.cs b/dotnet/src/Agents/AzureAI/AzureAIAgent.cs index 912bd83778fe..0ee773037885 100644 --- a/dotnet/src/Agents/AzureAI/AzureAIAgent.cs +++ b/dotnet/src/Agents/AzureAI/AzureAIAgent.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System.Collections.Generic; +using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; using Azure.AI.Projects; @@ -9,6 +10,7 @@ using Microsoft.SemanticKernel.Agents.Extensions; using Microsoft.SemanticKernel.ChatCompletion; using Microsoft.SemanticKernel.Diagnostics; +using AAIP = Azure.AI.Projects; namespace Microsoft.SemanticKernel.Agents.AzureAI; @@ -116,7 +118,7 @@ public Task AddChatMessageAsync(string threadId, ChatMessageContent message, Can /// An asynchronous enumeration of messages. public IAsyncEnumerable GetThreadMessagesAsync(string threadId, CancellationToken cancellationToken = default) { - return AgentThreadActions.GetMessagesAsync(this.Client, threadId, cancellationToken); + return AgentThreadActions.GetMessagesAsync(this.Client, threadId, null, cancellationToken); } /// @@ -139,6 +141,45 @@ public IAsyncEnumerable InvokeAsync( return this.InvokeAsync(threadId, options: null, arguments, kernel, cancellationToken); } + /// + public override async IAsyncEnumerable> InvokeAsync( + ChatMessageContent message, + AgentThread? thread = null, + KernelArguments? arguments = null, + Kernel? kernel = null, + AgentInvokeOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + Verify.NotNull(message); + + var azureAIAgentThread = await this.EnsureThreadExistsWithMessageAsync( + message, + thread, + () => new AzureAIAgentThread(this.Client), + cancellationToken).ConfigureAwait(false); + + // Create options that include the additional instructions. + var internalOptions = string.IsNullOrWhiteSpace(options?.AdditionalInstructions) ? null : new AzureAIInvocationOptions() + { + AdditionalInstructions = options?.AdditionalInstructions, + }; + + // Invoke the Agent with the thread that we already added our message to. + var invokeResults = this.InvokeAsync( + azureAIAgentThread.Id!, + internalOptions, + this.MergeArguments(arguments), + kernel ?? this.Kernel, + cancellationToken); + + // Notify the thread of new messages and return them to the caller. + await foreach (var result in invokeResults.ConfigureAwait(false)) + { + await azureAIAgentThread.OnNewMessageAsync(result, cancellationToken).ConfigureAwait(false); + yield return new(result, azureAIAgentThread); + } + } + /// /// Invokes the assistant on the specified thread. /// @@ -178,6 +219,52 @@ async IAsyncEnumerable InternalInvokeAsync() } } + /// + public async override IAsyncEnumerable> InvokeStreamingAsync( + ChatMessageContent message, + AgentThread? thread = null, + KernelArguments? arguments = null, + Kernel? kernel = null, + AgentInvokeOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + Verify.NotNull(message); + + var azureAIAgentThread = await this.EnsureThreadExistsWithMessageAsync( + message, + thread, + () => new AzureAIAgentThread(this.Client), + cancellationToken).ConfigureAwait(false); + + // Create options that include the additional instructions. + var internalOptions = string.IsNullOrWhiteSpace(options?.AdditionalInstructions) ? null : new AzureAIInvocationOptions() + { + AdditionalInstructions = options?.AdditionalInstructions, + }; + + // Invoke the Agent with the thread that we already added our message to. + var newMessagesReceiver = new ChatHistory(); + var invokeResults = this.InvokeStreamingAsync( + azureAIAgentThread.Id!, + internalOptions, + this.MergeArguments(arguments), + kernel ?? this.Kernel, + newMessagesReceiver, + cancellationToken); + + // Return the chunks to the caller. + await foreach (var result in invokeResults.ConfigureAwait(false)) + { + yield return new(result, azureAIAgentThread); + } + + // Notify the thread of any new messages that were assembled from the streaming response. + foreach (var newMessage in newMessagesReceiver) + { + await azureAIAgentThread.OnNewMessageAsync(newMessage, cancellationToken).ConfigureAwait(false); + } + } + /// /// Invokes the assistant on the specified thread with streaming response. /// @@ -276,7 +363,7 @@ protected override async Task RestoreChannelAsync(string channelSt this.Logger.LogAzureAIAgentRestoringChannel(nameof(RestoreChannelAsync), nameof(AzureAIChannel), threadId); - AgentThread thread = await this.Client.GetThreadAsync(threadId, cancellationToken).ConfigureAwait(false); + AAIP.AgentThread thread = await this.Client.GetThreadAsync(threadId, cancellationToken).ConfigureAwait(false); this.Logger.LogAzureAIAgentRestoredChannel(nameof(RestoreChannelAsync), nameof(AzureAIChannel), threadId); diff --git a/dotnet/src/Agents/AzureAI/AzureAIAgentThread.cs b/dotnet/src/Agents/AzureAI/AzureAIAgentThread.cs new file mode 100644 index 000000000000..c5c0090ea170 --- /dev/null +++ b/dotnet/src/Agents/AzureAI/AzureAIAgentThread.cs @@ -0,0 +1,99 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Azure.AI.Projects; +using Microsoft.SemanticKernel.Agents.AzureAI.Internal; + +namespace Microsoft.SemanticKernel.Agents.AzureAI; + +/// +/// Represents a conversation thread for an Azure AI agent. +/// +public class AzureAIAgentThread : AgentThread +{ + private readonly AgentsClient _client; + private string? _id = null; + + /// + /// Initializes a new instance of the class. + /// + /// The agents client to use for interacting with threads. + public AzureAIAgentThread(AgentsClient client) + { + Verify.NotNull(client); + + this._client = client; + } + + /// + /// Initializes a new instance of the class that resumes an existing thread. + /// + /// The agents client to use for interacting with threads. + /// The ID of an existing thread to resume. + public AzureAIAgentThread(AgentsClient client, string id) + { + Verify.NotNull(client); + Verify.NotNull(id); + + this._client = client; + this._id = id; + } + + /// + public override string? Id => this._id; + + /// + public override async Task CreateAsync(CancellationToken cancellationToken = default) + { + if (this._id is not null) + { + return this._id; + } + + var assistantThreadResponse = await this._client.CreateThreadAsync(cancellationToken: cancellationToken).ConfigureAwait(false); + this._id = assistantThreadResponse.Value.Id; + + return assistantThreadResponse.Value.Id; + } + + /// + public override async Task DeleteAsync(CancellationToken cancellationToken = default) + { + if (this._id is null) + { + throw new InvalidOperationException("This thread cannot be ended, since it has not been started."); + } + + await this._client.DeleteThreadAsync(this._id, cancellationToken).ConfigureAwait(false); + this._id = null; + } + + /// + public override async Task OnNewMessageAsync(ChatMessageContent newMessage, CancellationToken cancellationToken = default) + { + if (this._id is null) + { + throw new InvalidOperationException("Messages cannot be added to this thread, since the thread has not been started."); + } + + // If the message was generated by this agent, it is already in the thread and we shouldn't add it again. + if (newMessage.Metadata == null || !newMessage.Metadata.TryGetValue("ThreadId", out var messageThreadId) || !string.Equals(messageThreadId, this._id)) + { + await AgentThreadActions.CreateMessageAsync(this._client, this._id!, newMessage, cancellationToken).ConfigureAwait(false); + } + } + + /// + public IAsyncEnumerable GetMessagesAsync(CancellationToken cancellationToken = default) + { + if (this._id is null) + { + throw new InvalidOperationException("The messages for this thread cannot be retrieved, since the thread has not been started."); + } + + return AgentThreadActions.GetMessagesAsync(this._client, this._id!, ListSortOrder.Ascending, cancellationToken); + } +} diff --git a/dotnet/src/Agents/AzureAI/AzureAIChannel.cs b/dotnet/src/Agents/AzureAI/AzureAIChannel.cs index c3979e10bcb3..fa5991d4ace8 100644 --- a/dotnet/src/Agents/AzureAI/AzureAIChannel.cs +++ b/dotnet/src/Agents/AzureAI/AzureAIChannel.cs @@ -47,7 +47,7 @@ protected override IAsyncEnumerable InvokeStreaming /// protected override IAsyncEnumerable GetHistoryAsync(CancellationToken cancellationToken) { - return AgentThreadActions.GetMessagesAsync(client, threadId, cancellationToken); + return AgentThreadActions.GetMessagesAsync(client, threadId, null, cancellationToken); } /// diff --git a/dotnet/src/Agents/AzureAI/Internal/AgentThreadActions.cs b/dotnet/src/Agents/AzureAI/Internal/AgentThreadActions.cs index 167349b63d11..9eb6495b68f8 100644 --- a/dotnet/src/Agents/AzureAI/Internal/AgentThreadActions.cs +++ b/dotnet/src/Agents/AzureAI/Internal/AgentThreadActions.cs @@ -15,6 +15,7 @@ using Microsoft.SemanticKernel.Agents.Extensions; using Microsoft.SemanticKernel.ChatCompletion; using Microsoft.SemanticKernel.Connectors.FunctionCalling; +using AAIP = Azure.AI.Projects; namespace Microsoft.SemanticKernel.Agents.AzureAI.Internal; @@ -45,7 +46,7 @@ internal static class AgentThreadActions /// The thread identifier public static async Task CreateThreadAsync(AgentsClient client, CancellationToken cancellationToken = default) { - AgentThread thread = await client.CreateThreadAsync(cancellationToken: cancellationToken).ConfigureAwait(false); + AAIP.AgentThread thread = await client.CreateThreadAsync(cancellationToken: cancellationToken).ConfigureAwait(false); return thread.Id; } @@ -85,9 +86,10 @@ await client.CreateMessageAsync( /// /// The assistant client /// The thread identifier + /// The order to return messages in. /// The to monitor for cancellation requests. The default is . /// Asynchronous enumeration of messages. - public static async IAsyncEnumerable GetMessagesAsync(AgentsClient client, string threadId, [EnumeratorCancellation] CancellationToken cancellationToken) + public static async IAsyncEnumerable GetMessagesAsync(AgentsClient client, string threadId, ListSortOrder? messageOrder, [EnumeratorCancellation] CancellationToken cancellationToken) { Dictionary agentNames = []; // Cache agent names by their identifier @@ -95,7 +97,7 @@ public static async IAsyncEnumerable GetMessagesAsync(Agents PageableList? messages = null; do { - messages = await client.GetMessagesAsync(threadId, runId: null, limit: null, ListSortOrder.Descending, after: lastId, before: null, cancellationToken).ConfigureAwait(false); + messages = await client.GetMessagesAsync(threadId, runId: null, limit: null, messageOrder ?? ListSortOrder.Descending, after: lastId, before: null, cancellationToken).ConfigureAwait(false); foreach (ThreadMessage message in messages) { lastId = message.Id; diff --git a/dotnet/src/Agents/Bedrock/BedrockAgent.cs b/dotnet/src/Agents/Bedrock/BedrockAgent.cs index a8b3a2b9f4fa..d5cdf0e6255d 100644 --- a/dotnet/src/Agents/Bedrock/BedrockAgent.cs +++ b/dotnet/src/Agents/Bedrock/BedrockAgent.cs @@ -73,6 +73,32 @@ public static string CreateSessionId() #region public methods + /// + public override IAsyncEnumerable> InvokeAsync( + ChatMessageContent message, + AgentThread? thread = null, + KernelArguments? arguments = null, + Kernel? kernel = null, + AgentInvokeOptions? options = null, + CancellationToken cancellationToken = default) + { + // TODO: Implement the InvokeAsync method for BedrockAgent. + throw new NotImplementedException(); + } + + /// + public override IAsyncEnumerable> InvokeStreamingAsync( + ChatMessageContent message, + AgentThread? thread = null, + KernelArguments? arguments = null, + Kernel? kernel = null, + AgentInvokeOptions? options = null, + CancellationToken cancellationToken = default) + { + // TODO: Implement the InvokeStreamingAsync method for BedrockAgent. + throw new NotImplementedException(); + } + /// /// Invoke the Bedrock agent with the given message. /// diff --git a/dotnet/src/Agents/Core/ChatCompletionAgent.cs b/dotnet/src/Agents/Core/ChatCompletionAgent.cs index ed3f1ce3d2c6..ddbe0bb5ac5c 100644 --- a/dotnet/src/Agents/Core/ChatCompletionAgent.cs +++ b/dotnet/src/Agents/Core/ChatCompletionAgent.cs @@ -57,6 +57,46 @@ public ChatCompletionAgent( /// public AuthorRole InstructionsRole { get; init; } = AuthorRole.System; + /// + public override async IAsyncEnumerable> InvokeAsync( + ChatMessageContent message, + AgentThread? thread = null, + KernelArguments? arguments = null, + Kernel? kernel = null, + AgentInvokeOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + Verify.NotNull(message); + + var chatHistoryAgentThread = await this.EnsureThreadExistsWithMessageAsync( + message, + thread, + () => new ChatHistoryAgentThread(), + cancellationToken).ConfigureAwait(false); + + // Invoke Chat Completion with the updated chat history. + var chatHistory = new ChatHistory(); + await foreach (var existingMessage in chatHistoryAgentThread.GetMessagesAsync(cancellationToken).ConfigureAwait(false)) + { + chatHistory.Add(existingMessage); + } + string agentName = this.GetDisplayName(); + var invokeResults = this.InternalInvokeAsync( + agentName, + chatHistory, + this.MergeArguments(arguments), + kernel ?? this.Kernel, + options?.AdditionalInstructions, + cancellationToken); + + // Notify the thread of new messages and return them to the caller. + await foreach (var result in invokeResults.ConfigureAwait(false)) + { + await chatHistoryAgentThread.OnNewMessageAsync(result, cancellationToken).ConfigureAwait(false); + yield return new(result, chatHistoryAgentThread); + } + } + /// public override IAsyncEnumerable InvokeAsync( ChatHistory history, @@ -68,8 +108,47 @@ public override IAsyncEnumerable InvokeAsync( return ActivityExtensions.RunWithActivityAsync( () => ModelDiagnostics.StartAgentInvocationActivity(this.Id, agentName, this.Description), - () => this.InternalInvokeAsync(agentName, history, arguments, kernel, cancellationToken), + () => this.InternalInvokeAsync(agentName, history, arguments, kernel, null, cancellationToken), + cancellationToken); + } + + /// + public override async IAsyncEnumerable> InvokeStreamingAsync( + ChatMessageContent message, + AgentThread? thread = null, + KernelArguments? arguments = null, + Kernel? kernel = null, + AgentInvokeOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + Verify.NotNull(message); + + var chatHistoryAgentThread = await this.EnsureThreadExistsWithMessageAsync( + message, + thread, + () => new ChatHistoryAgentThread(), + cancellationToken).ConfigureAwait(false); + + // Invoke Chat Completion with the updated chat history. + var chatHistory = new ChatHistory(); + await foreach (var existingMessage in chatHistoryAgentThread.GetMessagesAsync(cancellationToken).ConfigureAwait(false)) + { + chatHistory.Add(existingMessage); + } + string agentName = this.GetDisplayName(); + var invokeResults = this.InternalInvokeStreamingAsync( + agentName, + chatHistory, + (newMessage) => chatHistoryAgentThread.OnNewMessageAsync(newMessage), + this.MergeArguments(arguments), + kernel ?? this.Kernel, + options?.AdditionalInstructions, cancellationToken); + + await foreach (var result in invokeResults.ConfigureAwait(false)) + { + yield return new(result, chatHistoryAgentThread); + } } /// @@ -83,7 +162,18 @@ public override IAsyncEnumerable InvokeStreamingAsy return ActivityExtensions.RunWithActivityAsync( () => ModelDiagnostics.StartAgentInvocationActivity(this.Id, agentName, this.Description), - () => this.InternalInvokeStreamingAsync(agentName, history, arguments, kernel, cancellationToken), + () => this.InternalInvokeStreamingAsync( + agentName, + history, + (newMessage) => + { + history.Add(newMessage); + return Task.CompletedTask; + }, + arguments, + kernel, + null, + cancellationToken), cancellationToken); } @@ -114,6 +204,7 @@ private async Task SetupAgentChatHistoryAsync( IReadOnlyList history, KernelArguments? arguments, Kernel kernel, + string? additionalInstructions, CancellationToken cancellationToken) { ChatHistory chat = []; @@ -125,6 +216,11 @@ private async Task SetupAgentChatHistoryAsync( chat.Add(new ChatMessageContent(this.InstructionsRole, instructions) { AuthorName = this.Name }); } + if (!string.IsNullOrWhiteSpace(additionalInstructions)) + { + chat.Add(new ChatMessageContent(AuthorRole.System, additionalInstructions) { AuthorName = this.Name }); + } + chat.AddRange(history); return chat; @@ -135,6 +231,7 @@ private async IAsyncEnumerable InternalInvokeAsync( ChatHistory history, KernelArguments? arguments = null, Kernel? kernel = null, + string? additionalInstructions = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) { kernel ??= this.Kernel; @@ -142,7 +239,7 @@ private async IAsyncEnumerable InternalInvokeAsync( (IChatCompletionService chatCompletionService, PromptExecutionSettings? executionSettings) = GetChatCompletionService(kernel, arguments); - ChatHistory chat = await this.SetupAgentChatHistoryAsync(history, arguments, kernel, cancellationToken).ConfigureAwait(false); + ChatHistory chat = await this.SetupAgentChatHistoryAsync(history, arguments, kernel, additionalInstructions, cancellationToken).ConfigureAwait(false); int messageCount = chat.Count; @@ -180,8 +277,10 @@ await chatCompletionService.GetChatMessageContentsAsync( private async IAsyncEnumerable InternalInvokeStreamingAsync( string agentName, ChatHistory history, + Func onNewMessage, KernelArguments? arguments = null, Kernel? kernel = null, + string? additionalInstructions = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) { kernel ??= this.Kernel; @@ -189,7 +288,7 @@ private async IAsyncEnumerable InternalInvokeStream (IChatCompletionService chatCompletionService, PromptExecutionSettings? executionSettings) = GetChatCompletionService(kernel, arguments); - ChatHistory chat = await this.SetupAgentChatHistoryAsync(history, arguments, kernel, cancellationToken).ConfigureAwait(false); + ChatHistory chat = await this.SetupAgentChatHistoryAsync(history, arguments, kernel, additionalInstructions, cancellationToken).ConfigureAwait(false); int messageCount = chat.Count; @@ -226,12 +325,14 @@ private async IAsyncEnumerable InternalInvokeStream message.AuthorName = this.Name; + await onNewMessage(message).ConfigureAwait(false); history.Add(message); } // Do not duplicate terminated function result to history if (role != AuthorRole.Tool) { + await onNewMessage(new(role ?? AuthorRole.Assistant, builder.ToString()) { AuthorName = this.Name }).ConfigureAwait(false); history.Add(new(role ?? AuthorRole.Assistant, builder.ToString()) { AuthorName = this.Name }); } } diff --git a/dotnet/src/Agents/Core/ChatHistoryAgentThread.cs b/dotnet/src/Agents/Core/ChatHistoryAgentThread.cs new file mode 100644 index 000000000000..cb13ead90e99 --- /dev/null +++ b/dotnet/src/Agents/Core/ChatHistoryAgentThread.cs @@ -0,0 +1,91 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.SemanticKernel.ChatCompletion; + +namespace Microsoft.SemanticKernel.Agents; + +/// +/// Represents a conversation thread based on an instance of that is maanged inside this class. +/// +public class ChatHistoryAgentThread : AgentThread +{ + private readonly ChatHistory _chatHistory = new(); + private string? _id = null; + + /// + /// Initializes a new instance of the class. + /// + public ChatHistoryAgentThread() + { + } + + /// + /// Initializes a new instance of the class that resumes an existing thread. + /// + /// An existing chat history to base this thread on. + /// The id of the existing thread. If not provided, a new one will be generated. + public ChatHistoryAgentThread(ChatHistory chatHistory, string? id = null) + { + Verify.NotNull(chatHistory); + this._chatHistory = chatHistory; + this._id = id ?? Guid.NewGuid().ToString("N"); + } + + /// + public override string? Id => this._id; + + /// + public override Task CreateAsync(CancellationToken cancellationToken = default) + { + if (this._id is not null) + { + return Task.FromResult(this._id); + } + + this._id = Guid.NewGuid().ToString("N"); + + return Task.FromResult(this._id); + } + + /// + public override Task DeleteAsync(CancellationToken cancellationToken = default) + { + if (this._id is null) + { + throw new InvalidOperationException("This thread cannot be ended, since it has not been started."); + } + + this._chatHistory.Clear(); + this._id = null; + + return Task.CompletedTask; + } + + /// + public override Task OnNewMessageAsync(ChatMessageContent newMessage, CancellationToken cancellationToken = default) + { + if (this._id is null) + { + throw new InvalidOperationException("Messages cannot be added to this thread, since the thread has not been started."); + } + + this._chatHistory.Add(newMessage); + return Task.CompletedTask; + } + + /// + public IAsyncEnumerable GetMessagesAsync(CancellationToken cancellationToken = default) + { + if (this._id is null) + { + throw new InvalidOperationException("The messages for this thread cannot be retrieved, since the thread has not been started."); + } + + return this._chatHistory.ToAsyncEnumerable(); + } +} diff --git a/dotnet/src/Agents/OpenAI/Internal/AssistantThreadActions.cs b/dotnet/src/Agents/OpenAI/Internal/AssistantThreadActions.cs index 64749cedff69..e1cb991b643e 100644 --- a/dotnet/src/Agents/OpenAI/Internal/AssistantThreadActions.cs +++ b/dotnet/src/Agents/OpenAI/Internal/AssistantThreadActions.cs @@ -65,13 +65,14 @@ await client.CreateMessageAsync( /// /// The assistant client /// The thread identifier + /// The order to return messages in. /// The to monitor for cancellation requests. The default is . /// Asynchronous enumeration of messages. - public static async IAsyncEnumerable GetMessagesAsync(AssistantClient client, string threadId, [EnumeratorCancellation] CancellationToken cancellationToken) + public static async IAsyncEnumerable GetMessagesAsync(AssistantClient client, string threadId, MessageCollectionOrder? messageOrder, [EnumeratorCancellation] CancellationToken cancellationToken) { Dictionary agentNames = []; // Cache agent names by their identifier - await foreach (ThreadMessage message in client.GetMessagesAsync(threadId, new() { Order = MessageCollectionOrder.Descending }, cancellationToken).ConfigureAwait(false)) + await foreach (ThreadMessage message in client.GetMessagesAsync(threadId, new() { Order = messageOrder ?? MessageCollectionOrder.Descending }, cancellationToken).ConfigureAwait(false)) { string? assistantName = null; if (!string.IsNullOrWhiteSpace(message.AssistantId) && diff --git a/dotnet/src/Agents/OpenAI/OpenAIAssistantAgent.cs b/dotnet/src/Agents/OpenAI/OpenAIAssistantAgent.cs index c8d300874c60..e16d130d6e46 100644 --- a/dotnet/src/Agents/OpenAI/OpenAIAssistantAgent.cs +++ b/dotnet/src/Agents/OpenAI/OpenAIAssistantAgent.cs @@ -336,7 +336,7 @@ public Task AddChatMessageAsync(string threadId, ChatMessageContent message, Can /// An asynchronous enumeration of messages. public IAsyncEnumerable GetThreadMessagesAsync(string threadId, CancellationToken cancellationToken = default) { - return AssistantThreadActions.GetMessagesAsync(this.Client, threadId, cancellationToken); + return AssistantThreadActions.GetMessagesAsync(this.Client, threadId, null, cancellationToken); } /// @@ -360,6 +360,45 @@ public async Task DeleteAsync(CancellationToken cancellationToken = defaul return this.IsDeleted; } + /// + public override async IAsyncEnumerable> InvokeAsync( + ChatMessageContent message, + AgentThread? thread = null, + KernelArguments? arguments = null, + Kernel? kernel = null, + AgentInvokeOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + Verify.NotNull(message); + + var openAIAssistantAgentThread = await this.EnsureThreadExistsWithMessageAsync( + message, + thread, + () => new OpenAIAssistantAgentThread(this.Client), + cancellationToken).ConfigureAwait(false); + + // Create options that include the additional instructions. + var internalOptions = string.IsNullOrWhiteSpace(options?.AdditionalInstructions) ? null : new RunCreationOptions() + { + AdditionalInstructions = options?.AdditionalInstructions, + }; + + // Invoke the Agent with the thread that we already added our message to. + var invokeResults = this.InvokeAsync( + openAIAssistantAgentThread.Id!, + internalOptions, + this.MergeArguments(arguments), + kernel ?? this.Kernel, + cancellationToken); + + // Notify the thread of new messages and return them to the caller. + await foreach (var result in invokeResults.ConfigureAwait(false)) + { + await openAIAssistantAgentThread.OnNewMessageAsync(result, cancellationToken).ConfigureAwait(false); + yield return new(result, openAIAssistantAgentThread); + } + } + /// /// Invokes the assistant on the specified thread. /// @@ -417,6 +456,52 @@ async IAsyncEnumerable InternalInvokeAsync() } } + /// + public async override IAsyncEnumerable> InvokeStreamingAsync( + ChatMessageContent message, + AgentThread? thread = null, + KernelArguments? arguments = null, + Kernel? kernel = null, + AgentInvokeOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + Verify.NotNull(message); + + var openAIAssistantAgentThread = await this.EnsureThreadExistsWithMessageAsync( + message, + thread, + () => new OpenAIAssistantAgentThread(this.Client), + cancellationToken).ConfigureAwait(false); + + // Create options that include the additional instructions. + var internalOptions = string.IsNullOrWhiteSpace(options?.AdditionalInstructions) ? null : new RunCreationOptions() + { + AdditionalInstructions = options?.AdditionalInstructions, + }; + + // Invoke the Agent with the thread that we already added our message to. + var newMessagesReceiver = new ChatHistory(); + var invokeResults = this.InvokeStreamingAsync( + openAIAssistantAgentThread.Id!, + internalOptions, + this.MergeArguments(arguments), + kernel ?? this.Kernel, + newMessagesReceiver, + cancellationToken); + + // Return the chunks to the caller. + await foreach (var result in invokeResults.ConfigureAwait(false)) + { + yield return new(result, openAIAssistantAgentThread); + } + + // Notify the thread of any new messages that were assembled from the streaming response. + foreach (var newMessage in newMessagesReceiver) + { + await openAIAssistantAgentThread.OnNewMessageAsync(newMessage, cancellationToken).ConfigureAwait(false); + } + } + /// /// Invokes the assistant on the specified thread with streaming response. /// diff --git a/dotnet/src/Agents/OpenAI/OpenAIAssistantAgentThread.cs b/dotnet/src/Agents/OpenAI/OpenAIAssistantAgentThread.cs new file mode 100644 index 000000000000..f6be22a77c91 --- /dev/null +++ b/dotnet/src/Agents/OpenAI/OpenAIAssistantAgentThread.cs @@ -0,0 +1,99 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.SemanticKernel.Agents.OpenAI.Internal; +using OpenAI.Assistants; + +namespace Microsoft.SemanticKernel.Agents.OpenAI; + +/// +/// Represents a conversation thread for an Open AI Assistant agent. +/// +public class OpenAIAssistantAgentThread : AgentThread +{ + private readonly AssistantClient _client; + private string? _id = null; + + /// + /// Initializes a new instance of the class. + /// + /// The assistant client to use for interacting with threads. + public OpenAIAssistantAgentThread(AssistantClient client) + { + Verify.NotNull(client); + + this._client = client; + } + + /// + /// Initializes a new instance of the class that resumes an existing thread. + /// + /// The assistant client to use for interacting with threads. + /// The ID of an existing thread to resume. + public OpenAIAssistantAgentThread(AssistantClient client, string id) + { + Verify.NotNull(client); + Verify.NotNull(id); + + this._client = client; + this._id = id; + } + + /// + public override string? Id => this._id; + + /// + public override async Task CreateAsync(CancellationToken cancellationToken = default) + { + if (this._id is not null) + { + return this._id; + } + + var assistantThreadResponse = await this._client.CreateThreadAsync(cancellationToken: cancellationToken).ConfigureAwait(false); + this._id = assistantThreadResponse.Value.Id; + + return assistantThreadResponse.Value.Id; + } + + /// + public override async Task DeleteAsync(CancellationToken cancellationToken = default) + { + if (this._id is null) + { + throw new InvalidOperationException("This thread cannot be ended, since it has not been started."); + } + + await this._client.DeleteThreadAsync(this._id, cancellationToken).ConfigureAwait(false); + this._id = null; + } + + /// + public override async Task OnNewMessageAsync(ChatMessageContent newMessage, CancellationToken cancellationToken = default) + { + if (this._id is null) + { + throw new InvalidOperationException("Messages cannot be added to this thread, since the thread has not been started."); + } + + // If the message was generated by this agent, it is already in the thread and we shouldn't add it again. + if (newMessage.Metadata == null || !newMessage.Metadata.TryGetValue("ThreadId", out var messageThreadId) || !string.Equals(messageThreadId, this._id)) + { + await AssistantThreadActions.CreateMessageAsync(this._client, this._id!, newMessage, cancellationToken).ConfigureAwait(false); + } + } + + /// + public IAsyncEnumerable GetMessagesAsync(CancellationToken cancellationToken = default) + { + if (this._id is null) + { + throw new InvalidOperationException("The messages for this thread cannot be retrieved, since the thread has not been started."); + } + + return AssistantThreadActions.GetMessagesAsync(this._client, this._id!, MessageCollectionOrder.Ascending, cancellationToken); + } +} diff --git a/dotnet/src/Agents/OpenAI/OpenAIAssistantChannel.cs b/dotnet/src/Agents/OpenAI/OpenAIAssistantChannel.cs index 4b91bac74178..39534df768da 100644 --- a/dotnet/src/Agents/OpenAI/OpenAIAssistantChannel.cs +++ b/dotnet/src/Agents/OpenAI/OpenAIAssistantChannel.cs @@ -52,7 +52,7 @@ protected override IAsyncEnumerable InvokeStreaming /// protected override IAsyncEnumerable GetHistoryAsync(CancellationToken cancellationToken) { - return AssistantThreadActions.GetMessagesAsync(this._client, this._threadId, cancellationToken); + return AssistantThreadActions.GetMessagesAsync(this._client, this._threadId, null, cancellationToken); } /// diff --git a/dotnet/src/Agents/UnitTests/MockAgent.cs b/dotnet/src/Agents/UnitTests/MockAgent.cs index 7f242ff510a5..b88ba37a3aa1 100644 --- a/dotnet/src/Agents/UnitTests/MockAgent.cs +++ b/dotnet/src/Agents/UnitTests/MockAgent.cs @@ -19,6 +19,18 @@ internal sealed class MockAgent : ChatHistoryKernelAgent public IReadOnlyList Response { get; set; } = []; + public override IAsyncEnumerable> InvokeAsync( + ChatMessageContent message, + AgentThread? thread = null, + KernelArguments? arguments = null, + Kernel? kernel = null, + AgentInvokeOptions? options = null, + CancellationToken cancellationToken = default) + { + this.InvokeCount++; + return this.Response.Select(x => new AgentResponseItem(x, thread!)).ToAsyncEnumerable(); + } + public override IAsyncEnumerable InvokeAsync( ChatHistory history, KernelArguments? arguments = null, @@ -30,6 +42,19 @@ public override IAsyncEnumerable InvokeAsync( return this.Response.ToAsyncEnumerable(); } + /// + public override IAsyncEnumerable> InvokeStreamingAsync( + ChatMessageContent message, + AgentThread? thread = null, + KernelArguments? arguments = null, + Kernel? kernel = null, + AgentInvokeOptions? options = null, + CancellationToken cancellationToken = default) + { + this.InvokeCount++; + return this.Response.Select(m => new AgentResponseItem(new StreamingChatMessageContent(m.Role, m.Content), thread!)).ToAsyncEnumerable(); + } + public override IAsyncEnumerable InvokeStreamingAsync( ChatHistory history, KernelArguments? arguments = null, diff --git a/dotnet/src/IntegrationTests/Agents/CommonInterfaceConformance/AgentFixture.cs b/dotnet/src/IntegrationTests/Agents/CommonInterfaceConformance/AgentFixture.cs new file mode 100644 index 000000000000..04b0b030ff04 --- /dev/null +++ b/dotnet/src/IntegrationTests/Agents/CommonInterfaceConformance/AgentFixture.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading.Tasks; +using Microsoft.SemanticKernel.Agents; +using Microsoft.SemanticKernel.ChatCompletion; +using Xunit; + +namespace SemanticKernel.IntegrationTests.Agents.CommonInterfaceConformance; + +/// +/// Base class for setting up and tearing down agents, to be used in tests. +/// Each agent type should have its own derived class. +/// +public abstract class AgentFixture : IAsyncLifetime +{ + public abstract Agent Agent { get; } + + public abstract AgentThread AgentThread { get; } + + public abstract Task GetChatHistory(); + + public abstract Task DeleteThread(AgentThread thread); + + public abstract Task DisposeAsync(); + + public abstract Task InitializeAsync(); +} diff --git a/dotnet/src/IntegrationTests/Agents/CommonInterfaceConformance/AzureAIAgentFixture.cs b/dotnet/src/IntegrationTests/Agents/CommonInterfaceConformance/AzureAIAgentFixture.cs new file mode 100644 index 000000000000..e944d506af2a --- /dev/null +++ b/dotnet/src/IntegrationTests/Agents/CommonInterfaceConformance/AzureAIAgentFixture.cs @@ -0,0 +1,77 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading.Tasks; +using Azure.Identity; +using Microsoft.Extensions.Configuration; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.Agents; +using Microsoft.SemanticKernel.Agents.AzureAI; +using Microsoft.SemanticKernel.ChatCompletion; +using SemanticKernel.IntegrationTests.TestSettings; +using AAIP = Azure.AI.Projects; + +namespace SemanticKernel.IntegrationTests.Agents.CommonInterfaceConformance; + +public class AzureAIAgentFixture : AgentFixture +{ + private readonly IConfigurationRoot _configuration = new ConfigurationBuilder() + .AddJsonFile(path: "testsettings.json", optional: true, reloadOnChange: true) + .AddJsonFile(path: "testsettings.development.json", optional: true, reloadOnChange: true) + .AddEnvironmentVariables() + .AddUserSecrets() + .Build(); + + private AAIP.AgentsClient? _agentsClient; + private AAIP.Agent? _aiAgent; + private AzureAIAgent? _agent; + private AzureAIAgentThread? _thread; + + public override Agent Agent => this._agent!; + + public override AgentThread AgentThread => this._thread!; + + public override async Task GetChatHistory() + { + var chatHistory = new ChatHistory(); + await foreach (var existingMessage in this._thread!.GetMessagesAsync().ConfigureAwait(false)) + { + chatHistory.Add(existingMessage); + } + return chatHistory; + } + + public override Task DeleteThread(AgentThread thread) + { + return this._agentsClient!.DeleteThreadAsync(thread.Id); + } + + public override async Task DisposeAsync() + { + if (this._thread!.Id is not null) + { + await this._agentsClient!.DeleteThreadAsync(this._thread!.Id); + } + + await this._agentsClient!.DeleteAgentAsync(this._aiAgent!.Id); + } + + public override async Task InitializeAsync() + { + AzureAIConfiguration configuration = this._configuration.GetSection("AzureAI").Get()!; + var client = AzureAIAgent.CreateAzureAIClient(configuration.ConnectionString, new AzureCliCredential()); + this._agentsClient = client.GetAgentsClient(); + + this._aiAgent = + await this._agentsClient.CreateAgentAsync( + configuration.ChatModelId, + name: "HelpfulAssistant", + description: "Helpful Assistant", + instructions: "You are a helpful assistant."); + + var kernelBuilder = Kernel.CreateBuilder(); + Kernel kernel = kernelBuilder.Build(); + + this._agent = new AzureAIAgent(this._aiAgent, this._agentsClient) { Kernel = kernel }; + this._thread = new AzureAIAgentThread(this._agentsClient); + } +} diff --git a/dotnet/src/IntegrationTests/Agents/CommonInterfaceConformance/AzureAIAgentInvokeTests.cs b/dotnet/src/IntegrationTests/Agents/CommonInterfaceConformance/AzureAIAgentInvokeTests.cs new file mode 100644 index 000000000000..5ee777fe67f5 --- /dev/null +++ b/dotnet/src/IntegrationTests/Agents/CommonInterfaceConformance/AzureAIAgentInvokeTests.cs @@ -0,0 +1,7 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace SemanticKernel.IntegrationTests.Agents.CommonInterfaceConformance; + +public class AzureAIAgentInvokeTests() : InvokeTests(() => new AzureAIAgentFixture()) +{ +} diff --git a/dotnet/src/IntegrationTests/Agents/CommonInterfaceConformance/ChatCompletionAgentFixture.cs b/dotnet/src/IntegrationTests/Agents/CommonInterfaceConformance/ChatCompletionAgentFixture.cs new file mode 100644 index 000000000000..f541b6cf7907 --- /dev/null +++ b/dotnet/src/IntegrationTests/Agents/CommonInterfaceConformance/ChatCompletionAgentFixture.cs @@ -0,0 +1,72 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading.Tasks; +using Azure.Identity; +using Microsoft.Extensions.Configuration; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.Agents; +using Microsoft.SemanticKernel.ChatCompletion; +using SemanticKernel.IntegrationTests.TestSettings; + +namespace SemanticKernel.IntegrationTests.Agents.CommonInterfaceConformance; + +/// +/// Contains setup and teardown for the tests. +/// +public class ChatCompletionAgentFixture : AgentFixture +{ + private readonly IConfigurationRoot _configuration = new ConfigurationBuilder() + .AddJsonFile(path: "testsettings.json", optional: true, reloadOnChange: true) + .AddJsonFile(path: "testsettings.development.json", optional: true, reloadOnChange: true) + .AddEnvironmentVariables() + .AddUserSecrets() + .Build(); + + private ChatCompletionAgent? _agent; + private ChatHistoryAgentThread? _thread; + + public override Agent Agent => this._agent!; + + public override AgentThread AgentThread => this._thread!; + + public override async Task GetChatHistory() + { + var chatHistory = new ChatHistory(); + await foreach (var existingMessage in this._thread!.GetMessagesAsync().ConfigureAwait(false)) + { + chatHistory.Add(existingMessage); + } + return chatHistory; + } + + public override Task DisposeAsync() + { + return Task.CompletedTask; + } + + public override Task DeleteThread(AgentThread thread) + { + return Task.CompletedTask; + } + + public override Task InitializeAsync() + { + AzureOpenAIConfiguration configuration = this._configuration.GetSection("AzureOpenAI").Get()!; + + var kernelBuilder = Kernel.CreateBuilder(); + kernelBuilder.AddAzureOpenAIChatCompletion( + deploymentName: configuration.ChatDeploymentName!, + endpoint: configuration.Endpoint, + credentials: new AzureCliCredential()); + Kernel kernel = kernelBuilder.Build(); + + this._agent = new ChatCompletionAgent() + { + Kernel = kernel, + Instructions = "You are a helpful assistant.", + }; + this._thread = new ChatHistoryAgentThread(); + + return Task.CompletedTask; + } +} diff --git a/dotnet/src/IntegrationTests/Agents/CommonInterfaceConformance/ChatCompletionAgentInvokeTests.cs b/dotnet/src/IntegrationTests/Agents/CommonInterfaceConformance/ChatCompletionAgentInvokeTests.cs new file mode 100644 index 000000000000..a234260e4620 --- /dev/null +++ b/dotnet/src/IntegrationTests/Agents/CommonInterfaceConformance/ChatCompletionAgentInvokeTests.cs @@ -0,0 +1,7 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace SemanticKernel.IntegrationTests.Agents.CommonInterfaceConformance; + +public class ChatCompletionAgentInvokeTests() : InvokeTests(() => new ChatCompletionAgentFixture()) +{ +} diff --git a/dotnet/src/IntegrationTests/Agents/CommonInterfaceConformance/InvokeTests.cs b/dotnet/src/IntegrationTests/Agents/CommonInterfaceConformance/InvokeTests.cs new file mode 100644 index 000000000000..1cee519982b4 --- /dev/null +++ b/dotnet/src/IntegrationTests/Agents/CommonInterfaceConformance/InvokeTests.cs @@ -0,0 +1,89 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.ChatCompletion; +using Microsoft.SemanticKernel.Agents; +using Xunit; + +namespace SemanticKernel.IntegrationTests.Agents.CommonInterfaceConformance; + +/// +/// Base test class for testing the method of agents. +/// Each agent type should have its own derived class. +/// +public abstract class InvokeTests(Func createAgentFixture) : IAsyncLifetime +{ +#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable. + private AgentFixture _agentFixture; +#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable. + + protected AgentFixture Fixture => this._agentFixture; + + [Fact] + public async Task InvokeReturnsResultAsync() + { + var agent = this.Fixture.Agent; + var asyncResults = agent.InvokeAsync(new ChatMessageContent(AuthorRole.User, "What is the capital of France."), this.Fixture.AgentThread); + var results = await asyncResults.ToListAsync(); + Assert.Single(results); + + var firstResult = results.First(); + Assert.Contains("Paris", firstResult.Message.Content); + Assert.NotNull(firstResult.Thread); + } + + [Fact] + public async Task InvokeWithoutThreadCreatesThreadAsync() + { + var agent = this.Fixture.Agent; + var asyncResults = agent.InvokeAsync(new ChatMessageContent(AuthorRole.User, "What is the capital of France.")); + var results = await asyncResults.ToListAsync(); + Assert.Single(results); + + var firstResult = results.First(); + Assert.Contains("Paris", firstResult.Message.Content); + Assert.NotNull(firstResult.Thread); + + await this.Fixture.DeleteThread(firstResult.Thread); + } + + [Fact] + public async Task ConversationMaintainsHistoryAsync() + { + var q1 = "What is the capital of France."; + var q2 = "What is the capital of Austria."; + + var agent = this.Fixture.Agent; + var asyncResults1 = agent.InvokeAsync(new ChatMessageContent(AuthorRole.User, q1), this.Fixture.AgentThread); + var result1 = await asyncResults1.FirstAsync(); + var asyncResults2 = agent.InvokeAsync(new ChatMessageContent(AuthorRole.User, q2), result1.Thread); + var result2 = await asyncResults2.FirstAsync(); + + Assert.Contains("Paris", result1.Message.Content); + Assert.Contains("Austria", result2.Message.Content); + + var chatHistory = await this.Fixture.GetChatHistory(); + + Assert.Equal(4, chatHistory.Count); + Assert.Equal(2, chatHistory.Count(x => x.Role == AuthorRole.User)); + Assert.Equal(2, chatHistory.Count(x => x.Role == AuthorRole.Assistant)); + Assert.Equal(q1, chatHistory[0].Content); + Assert.Equal(q2, chatHistory[2].Content); + Assert.Contains("Paris", chatHistory[1].Content); + Assert.Contains("Vienna", chatHistory[3].Content); + } + + public Task InitializeAsync() + { + this._agentFixture = createAgentFixture(); + return this._agentFixture.InitializeAsync(); + } + + public Task DisposeAsync() + { + return this._agentFixture.DisposeAsync(); + } +} diff --git a/dotnet/src/IntegrationTests/Agents/CommonInterfaceConformance/OpenAIAssistantAgentFixture.cs b/dotnet/src/IntegrationTests/Agents/CommonInterfaceConformance/OpenAIAssistantAgentFixture.cs new file mode 100644 index 000000000000..c62d913a4b0f --- /dev/null +++ b/dotnet/src/IntegrationTests/Agents/CommonInterfaceConformance/OpenAIAssistantAgentFixture.cs @@ -0,0 +1,80 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading.Tasks; +using Azure.Identity; +using Microsoft.Extensions.Configuration; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.Agents; +using Microsoft.SemanticKernel.Agents.OpenAI; +using Microsoft.SemanticKernel.ChatCompletion; +using OpenAI.Assistants; +using SemanticKernel.IntegrationTests.TestSettings; + +namespace SemanticKernel.IntegrationTests.Agents.CommonInterfaceConformance; + +/// +/// Contains setup and teardown for the tests. +/// +public class OpenAIAssistantAgentFixture : AgentFixture +{ + private readonly IConfigurationRoot _configuration = new ConfigurationBuilder() + .AddJsonFile(path: "testsettings.json", optional: true, reloadOnChange: true) + .AddJsonFile(path: "testsettings.development.json", optional: true, reloadOnChange: true) + .AddEnvironmentVariables() + .AddUserSecrets() + .Build(); + + private AssistantClient? _assistantClient; + private Assistant? _assistant; + private OpenAIAssistantAgent? _agent; + private OpenAIAssistantAgentThread? _thread; + + public override Agent Agent => this._agent!; + + public override AgentThread AgentThread => this._thread!; + + public override async Task GetChatHistory() + { + var chatHistory = new ChatHistory(); + await foreach (var existingMessage in this._thread!.GetMessagesAsync().ConfigureAwait(false)) + { + chatHistory.Add(existingMessage); + } + return chatHistory; + } + + public override async Task DisposeAsync() + { + if (this._thread!.Id is not null) + { + await this._assistantClient!.DeleteThreadAsync(this._thread!.Id); + } + + await this._assistantClient!.DeleteAssistantAsync(this._assistant!.Id); + } + + public override Task DeleteThread(AgentThread thread) + { + return this._assistantClient!.DeleteThreadAsync(thread.Id); + } + + public override async Task InitializeAsync() + { + AzureOpenAIConfiguration configuration = this._configuration.GetSection("AzureOpenAI").Get()!; + var client = OpenAIAssistantAgent.CreateAzureOpenAIClient(new AzureCliCredential(), new Uri(configuration.Endpoint)); + this._assistantClient = client.GetAssistantClient(); + + this._assistant = + await this._assistantClient.CreateAssistantAsync( + configuration.ModelId, + name: "HelpfulAssistant", + instructions: "You are a helpful assistant."); + + var kernelBuilder = Kernel.CreateBuilder(); + Kernel kernel = kernelBuilder.Build(); + + this._agent = new OpenAIAssistantAgent(this._assistant, this._assistantClient) { Kernel = kernel }; + this._thread = new OpenAIAssistantAgentThread(this._assistantClient); + } +} diff --git a/dotnet/src/IntegrationTests/Agents/CommonInterfaceConformance/OpenAIAssistantAgentInvokeTests.cs b/dotnet/src/IntegrationTests/Agents/CommonInterfaceConformance/OpenAIAssistantAgentInvokeTests.cs new file mode 100644 index 000000000000..31e63181d910 --- /dev/null +++ b/dotnet/src/IntegrationTests/Agents/CommonInterfaceConformance/OpenAIAssistantAgentInvokeTests.cs @@ -0,0 +1,7 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace SemanticKernel.IntegrationTests.Agents.CommonInterfaceConformance; + +public class OpenAIAssistantAgentInvokeTests() : InvokeTests(() => new OpenAIAssistantAgentFixture()) +{ +} diff --git a/dotnet/src/IntegrationTests/IntegrationTests.csproj b/dotnet/src/IntegrationTests/IntegrationTests.csproj index 917e2447fea3..cb21d20b7f4a 100644 --- a/dotnet/src/IntegrationTests/IntegrationTests.csproj +++ b/dotnet/src/IntegrationTests/IntegrationTests.csproj @@ -67,6 +67,7 @@ + diff --git a/dotnet/src/IntegrationTests/TestSettings/AzureAIConfiguration.cs b/dotnet/src/IntegrationTests/TestSettings/AzureAIConfiguration.cs new file mode 100644 index 000000000000..0e3993fafc0b --- /dev/null +++ b/dotnet/src/IntegrationTests/TestSettings/AzureAIConfiguration.cs @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; + +namespace SemanticKernel.IntegrationTests.TestSettings; + +[SuppressMessage("Performance", "CA1812:Internal class that is apparently never instantiated", + Justification = "Configuration classes are instantiated through IConfiguration.")] +internal sealed class AzureAIConfiguration(string connectionString, string chatModelId) +{ + public string ConnectionString { get; set; } = connectionString; + + public string ChatModelId { get; set; } = chatModelId; +}