From f67a35fe71f9121c10461ef668a97eefb28266b0 Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Wed, 19 Mar 2025 19:52:26 +0000 Subject: [PATCH 01/10] Add a common agent invoke api. --- .../AgentInvokeResponseAsyncEnumerable.cs | 39 ++++++++ dotnet/src/Agents/Abstractions/AgentThread.cs | 52 ++++++++++ .../IAgentInvokeResponseAsyncEnumerable.cs | 17 ++++ dotnet/src/Agents/AzureAI/AzureAIAgent.cs | 52 +++++++++- .../src/Agents/AzureAI/AzureAIAgentThread.cs | 94 +++++++++++++++++++ .../AzureAI/Internal/AgentThreadActions.cs | 3 +- dotnet/src/Agents/Core/ChatCompletionAgent.cs | 59 +++++++++++- .../src/Agents/Core/ChatHistoryAgentThread.cs | 83 ++++++++++++++++ .../src/Agents/OpenAI/OpenAIAssistantAgent.cs | 47 ++++++++++ .../OpenAI/OpenAIAssistantAgentThread.cs | 94 +++++++++++++++++++ 10 files changed, 534 insertions(+), 6 deletions(-) create mode 100644 dotnet/src/Agents/Abstractions/AgentInvokeResponseAsyncEnumerable.cs create mode 100644 dotnet/src/Agents/Abstractions/AgentThread.cs create mode 100644 dotnet/src/Agents/Abstractions/IAgentInvokeResponseAsyncEnumerable.cs create mode 100644 dotnet/src/Agents/AzureAI/AzureAIAgentThread.cs create mode 100644 dotnet/src/Agents/Core/ChatHistoryAgentThread.cs create mode 100644 dotnet/src/Agents/OpenAI/OpenAIAssistantAgentThread.cs diff --git a/dotnet/src/Agents/Abstractions/AgentInvokeResponseAsyncEnumerable.cs b/dotnet/src/Agents/Abstractions/AgentInvokeResponseAsyncEnumerable.cs new file mode 100644 index 000000000000..99b74705f7c2 --- /dev/null +++ b/dotnet/src/Agents/Abstractions/AgentInvokeResponseAsyncEnumerable.cs @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Threading; + +namespace Microsoft.SemanticKernel.Agents; + +/// +/// Represents a response from an agent invocation. +/// +/// The type of data returned by the response. +public class AgentInvokeResponseAsyncEnumerable : IAgentInvokeResponseAsyncEnumerable +{ + private readonly IAsyncEnumerable _asyncEnumerable; + private readonly AgentThread _thread; + + /// + /// Initializes a new instance of the class. + /// + /// The internal that will be combined with additional invoke specific response information. + /// The conversation thread associated with the response. + public AgentInvokeResponseAsyncEnumerable(IAsyncEnumerable asyncEnumerable, AgentThread thread) + { + Verify.NotNull(asyncEnumerable); + Verify.NotNull(thread); + + this._asyncEnumerable = asyncEnumerable; + this._thread = thread; + } + + /// + public AgentThread Thread => this._thread; + + /// + public IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default) + { + return this._asyncEnumerable.GetAsyncEnumerator(cancellationToken); + } +} diff --git a/dotnet/src/Agents/Abstractions/AgentThread.cs b/dotnet/src/Agents/Abstractions/AgentThread.cs new file mode 100644 index 000000000000..e718cb941e8b --- /dev/null +++ b/dotnet/src/Agents/Abstractions/AgentThread.cs @@ -0,0 +1,52 @@ +// 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 a value indicating whether the thread is currently active. + /// + public abstract bool IsActive { get; } + + /// + /// Gets the id of the current thread. + /// + public abstract string? ThreadId { get; } + + /// + /// Starts 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 StartThreadAsync(CancellationToken cancellationToken = default); + + /// + /// Ends 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 EndThreadAsync(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/IAgentInvokeResponseAsyncEnumerable.cs b/dotnet/src/Agents/Abstractions/IAgentInvokeResponseAsyncEnumerable.cs new file mode 100644 index 000000000000..ba1775289830 --- /dev/null +++ b/dotnet/src/Agents/Abstractions/IAgentInvokeResponseAsyncEnumerable.cs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; + +namespace Microsoft.SemanticKernel.Agents; + +/// +/// Represents a response from an agent invocation. +/// +/// The type of data returned by the response. +public interface IAgentInvokeResponseAsyncEnumerable : IAsyncEnumerable +{ + /// + /// Gets the thread associated with this response. + /// + AgentThread Thread { get; } +} diff --git a/dotnet/src/Agents/AzureAI/AzureAIAgent.cs b/dotnet/src/Agents/AzureAI/AzureAIAgent.cs index 912bd83778fe..8052a9969af2 100644 --- a/dotnet/src/Agents/AzureAI/AzureAIAgent.cs +++ b/dotnet/src/Agents/AzureAI/AzureAIAgent.cs @@ -1,6 +1,8 @@ // Copyright (c) Microsoft. All rights reserved. +using System; using System.Collections.Generic; +using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; using Azure.AI.Projects; @@ -9,6 +11,7 @@ using Microsoft.SemanticKernel.Agents.Extensions; using Microsoft.SemanticKernel.ChatCompletion; using Microsoft.SemanticKernel.Diagnostics; +using AAIP = Azure.AI.Projects; namespace Microsoft.SemanticKernel.Agents.AzureAI; @@ -139,6 +142,53 @@ public IAsyncEnumerable InvokeAsync( return this.InvokeAsync(threadId, options: null, arguments, kernel, cancellationToken); } + /// + public async Task> InvokeAsync( + ChatMessageContent message, + AgentThread? thread = null, + KernelArguments? arguments = null, + Kernel? kernel = null, + string? additionalInstructions = null, + CancellationToken cancellationToken = default) + { + Verify.NotNull(message); + + if (thread is null) + { + thread = new AzureAIAgentThread(this.Client); + } + + if (thread is not AzureAIAgentThread) + { + throw new KernelException($"{nameof(AzureAIAgent)} currently only supports agent threads of type {nameof(AzureAIAgentThread)}."); + } + + if (!thread.IsActive) + { + await thread.StartThreadAsync(cancellationToken).ConfigureAwait(false); + } + + // Notify the thread that a new message is availble. + await thread.OnNewMessageAsync(message, cancellationToken).ConfigureAwait(false); + + // Create options that include the additional instructions. + var options = string.IsNullOrWhiteSpace(additionalInstructions) ? null : new AzureAIInvocationOptions() + { + AdditionalInstructions = additionalInstructions, + }; + + // Invoke the Agent with the thread that we already added our message to. + var invokeResults = this.InvokeAsync(thread.ThreadId!, options, arguments, kernel, cancellationToken); + + // Notify the thread of any new messages returned by the agent. + await foreach (var result in invokeResults.ConfigureAwait(false)) + { + await thread.OnNewMessageAsync(result, cancellationToken).ConfigureAwait(false); + } + + return new AgentInvokeResponseAsyncEnumerable(invokeResults, thread); + } + /// /// Invokes the assistant on the specified thread. /// @@ -276,7 +326,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..25a95f3bc3e6 --- /dev/null +++ b/dotnet/src/Agents/AzureAI/AzureAIAgentThread.cs @@ -0,0 +1,94 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +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 bool _isActive = false; + private string? _threadId = 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. + /// + /// The agents client to use for interacting with threads. + /// The ID of an existing thread to resume. + public AzureAIAgentThread(AgentsClient client, string threadId) + { + Verify.NotNull(client); + Verify.NotNull(threadId); + + this._client = client; + this._isActive = true; + this._threadId = threadId; + } + + /// + public override bool IsActive => this._isActive; + + /// + public override string? ThreadId => this._threadId; + + /// + public override async Task StartThreadAsync(CancellationToken cancellationToken = default) + { + if (this._isActive) + { + throw new InvalidOperationException("You cannot start this thread, since the thread is already active."); + } + + var assitantThreadResponse = await this._client.CreateThreadAsync(cancellationToken: cancellationToken).ConfigureAwait(false); + this._threadId = assitantThreadResponse.Value.Id; + this._isActive = true; + + return assitantThreadResponse.Value.Id; + } + + /// + public override async Task EndThreadAsync(CancellationToken cancellationToken = default) + { + if (!this._isActive) + { + throw new InvalidOperationException("This thread cannot be ended, since the thread is not currently active."); + } + + await this._client.DeleteThreadAsync(this._threadId, cancellationToken).ConfigureAwait(false); + this._isActive = false; + this._threadId = null; + } + + /// + public override async Task OnNewMessageAsync(ChatMessageContent newMessage, CancellationToken cancellationToken = default) + { + if (!this._isActive) + { + throw new InvalidOperationException("Messages cannot be added to this thread, since the thread is not currently active."); + } + + // 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._threadId)) + { + await AgentThreadActions.CreateMessageAsync(this._client, this._threadId!, newMessage, cancellationToken).ConfigureAwait(false); + } + } +} diff --git a/dotnet/src/Agents/AzureAI/Internal/AgentThreadActions.cs b/dotnet/src/Agents/AzureAI/Internal/AgentThreadActions.cs index 167349b63d11..70cb5bffa97a 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; } diff --git a/dotnet/src/Agents/Core/ChatCompletionAgent.cs b/dotnet/src/Agents/Core/ChatCompletionAgent.cs index ed3f1ce3d2c6..ded6cbdd8f67 100644 --- a/dotnet/src/Agents/Core/ChatCompletionAgent.cs +++ b/dotnet/src/Agents/Core/ChatCompletionAgent.cs @@ -57,6 +57,49 @@ public ChatCompletionAgent( /// public AuthorRole InstructionsRole { get; init; } = AuthorRole.System; + /// + public async Task> InvokeAsync( + ChatMessageContent message, + AgentThread? thread = null, + KernelArguments? arguments = null, + Kernel? kernel = null, + string? additionalInstructions = null, + CancellationToken cancellationToken = default) + { + Verify.NotNull(message); + + if (thread == null) + { + thread = new ChatHistoryAgentThread(); + } + + if (thread is not ChatHistoryAgentThread chatHistoryAgentThread) + { + throw new KernelException($"{nameof(ChatCompletionAgent)} currently only supports agent threads of type {nameof(ChatHistoryAgentThread)}."); + } + + if (!thread.IsActive) + { + await thread.StartThreadAsync(cancellationToken).ConfigureAwait(false); + } + + // Notify the thread that a new message is availble and get the updated chat history. + await thread.OnNewMessageAsync(message, cancellationToken).ConfigureAwait(false); + var chatHistory = await chatHistoryAgentThread.RetrieveCurrentChatHistoryAsync(cancellationToken).ConfigureAwait(false); + + // Invoke Chat Completion with the updated chat history. + string agentName = this.GetDisplayName(); + var invokeResults = this.InternalInvokeAsync(agentName, chatHistory, arguments, kernel, additionalInstructions, cancellationToken); + + // Notify the thread of any new messages returned by chat completion. + await foreach (var result in invokeResults.ConfigureAwait(false)) + { + await thread.OnNewMessageAsync(result, cancellationToken).ConfigureAwait(false); + } + + return new AgentInvokeResponseAsyncEnumerable(invokeResults, thread); + } + /// public override IAsyncEnumerable InvokeAsync( ChatHistory history, @@ -68,7 +111,7 @@ 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); } @@ -83,7 +126,7 @@ 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, arguments, kernel, null, cancellationToken), cancellationToken); } @@ -114,6 +157,7 @@ private async Task SetupAgentChatHistoryAsync( IReadOnlyList history, KernelArguments? arguments, Kernel kernel, + string? additionalInstructions, CancellationToken cancellationToken) { ChatHistory chat = []; @@ -125,6 +169,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 +184,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 +192,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; @@ -182,6 +232,7 @@ private async IAsyncEnumerable InternalInvokeStream ChatHistory history, KernelArguments? arguments = null, Kernel? kernel = null, + string? additionalInstructions = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) { kernel ??= this.Kernel; @@ -189,7 +240,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; diff --git a/dotnet/src/Agents/Core/ChatHistoryAgentThread.cs b/dotnet/src/Agents/Core/ChatHistoryAgentThread.cs new file mode 100644 index 000000000000..c0c59925faa6 --- /dev/null +++ b/dotnet/src/Agents/Core/ChatHistoryAgentThread.cs @@ -0,0 +1,83 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +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 bool _isActive = false; + private string? _threadId = null; + + /// + /// Initializes a new instance of the class. + /// + public ChatHistoryAgentThread() + { + } + + /// + public override bool IsActive => this._isActive; + + /// + public override string? ThreadId => this._threadId; + + /// + public override Task StartThreadAsync(CancellationToken cancellationToken = default) + { + if (this._isActive) + { + throw new InvalidOperationException("You cannot start this thread, since the thread is already active."); + } + + this._threadId = Guid.NewGuid().ToString("N"); + this._isActive = true; + + return Task.FromResult(this._threadId); + } + + /// + public override Task EndThreadAsync(CancellationToken cancellationToken = default) + { + if (!this._isActive) + { + throw new InvalidOperationException("This thread cannot be ended, since the thread is not currently active."); + } + + this._chatHistory.Clear(); + this._threadId = null; + this._isActive = false; + + return Task.CompletedTask; + } + + /// + public override Task OnNewMessageAsync(ChatMessageContent newMessage, CancellationToken cancellationToken = default) + { + if (!this._isActive) + { + throw new InvalidOperationException("Messages cannot be added to this thread, since the thread is not currently active."); + } + + this._chatHistory.Add(newMessage); + return Task.CompletedTask; + } + + /// + public Task RetrieveCurrentChatHistoryAsync(CancellationToken cancellationToken = default) + { + if (!this._isActive) + { + throw new InvalidOperationException("The chat history for this thread cannot be retrieved, since the thread is not currently active."); + } + + return Task.FromResult(this._chatHistory); + } +} diff --git a/dotnet/src/Agents/OpenAI/OpenAIAssistantAgent.cs b/dotnet/src/Agents/OpenAI/OpenAIAssistantAgent.cs index c8d300874c60..82bb280a4d15 100644 --- a/dotnet/src/Agents/OpenAI/OpenAIAssistantAgent.cs +++ b/dotnet/src/Agents/OpenAI/OpenAIAssistantAgent.cs @@ -360,6 +360,53 @@ public async Task DeleteAsync(CancellationToken cancellationToken = defaul return this.IsDeleted; } + /// + public async Task> InvokeAsync( + ChatMessageContent message, + AgentThread? thread = null, + KernelArguments? arguments = null, + Kernel? kernel = null, + string? additionalInstructions = null, + CancellationToken cancellationToken = default) + { + Verify.NotNull(message); + + if (thread is null) + { + thread = new OpenAIAssistantAgentThread(this.Client); + } + + if (thread is not OpenAIAssistantAgentThread) + { + throw new KernelException($"{nameof(OpenAIAssistantAgent)} currently only supports agent threads of type {nameof(OpenAIAssistantAgentThread)}."); + } + + if (!thread.IsActive) + { + await thread.StartThreadAsync(cancellationToken).ConfigureAwait(false); + } + + // Notify the thread that a new message is availble. + await thread.OnNewMessageAsync(message, cancellationToken).ConfigureAwait(false); + + // Create options that include the additional instructions. + var options = string.IsNullOrWhiteSpace(additionalInstructions) ? null : new RunCreationOptions() + { + AdditionalInstructions = additionalInstructions, + }; + + // Invoke the Agent with the thread that we already added our message to. + var invokeResults = this.InvokeAsync(thread.ThreadId!, options, arguments, kernel, cancellationToken); + + // Notify the thread of any new messages returned by the agent. + await foreach (var result in invokeResults.ConfigureAwait(false)) + { + await thread.OnNewMessageAsync(result, cancellationToken).ConfigureAwait(false); + } + + return new AgentInvokeResponseAsyncEnumerable(invokeResults, thread); + } + /// /// Invokes the assistant on the specified thread. /// diff --git a/dotnet/src/Agents/OpenAI/OpenAIAssistantAgentThread.cs b/dotnet/src/Agents/OpenAI/OpenAIAssistantAgentThread.cs new file mode 100644 index 000000000000..336c0e53f37a --- /dev/null +++ b/dotnet/src/Agents/OpenAI/OpenAIAssistantAgentThread.cs @@ -0,0 +1,94 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +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 bool _isActive = false; + private string? _threadId = 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. + /// + /// The assistant client to use for interacting with threads. + /// The ID of an existing thread to resume. + public OpenAIAssistantAgentThread(AssistantClient client, string threadId) + { + Verify.NotNull(client); + Verify.NotNull(threadId); + + this._client = client; + this._isActive = true; + this._threadId = threadId; + } + + /// + public override bool IsActive => this._isActive; + + /// + public override string? ThreadId => this._threadId; + + /// + public override async Task StartThreadAsync(CancellationToken cancellationToken = default) + { + if (this._isActive) + { + throw new InvalidOperationException("You cannot start this thread, since the thread is already active."); + } + + var assitantThreadResponse = await this._client.CreateThreadAsync(cancellationToken: cancellationToken).ConfigureAwait(false); + this._threadId = assitantThreadResponse.Value.Id; + this._isActive = true; + + return assitantThreadResponse.Value.Id; + } + + /// + public override async Task EndThreadAsync(CancellationToken cancellationToken = default) + { + if (!this._isActive) + { + throw new InvalidOperationException("This thread cannot be ended, since the thread is not currently active."); + } + + await this._client.DeleteThreadAsync(this._threadId, cancellationToken).ConfigureAwait(false); + this._threadId = null; + this._threadId = null; + } + + /// + public override async Task OnNewMessageAsync(ChatMessageContent newMessage, CancellationToken cancellationToken = default) + { + if (!this._isActive) + { + throw new InvalidOperationException("Messages cannot be added to this thread, since the thread is not currently active."); + } + + // 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._threadId)) + { + await AssistantThreadActions.CreateMessageAsync(this._client, this._threadId!, newMessage, cancellationToken).ConfigureAwait(false); + } + } +} From f13b5703567c43efe99b2c19a2fe40905a402640 Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Wed, 19 Mar 2025 19:57:37 +0000 Subject: [PATCH 02/10] Fix typos and formatting. --- dotnet/src/Agents/AzureAI/AzureAIAgent.cs | 4 +--- dotnet/src/Agents/AzureAI/AzureAIAgentThread.cs | 6 +++--- dotnet/src/Agents/Core/ChatCompletionAgent.cs | 2 +- dotnet/src/Agents/OpenAI/OpenAIAssistantAgent.cs | 2 +- dotnet/src/Agents/OpenAI/OpenAIAssistantAgentThread.cs | 6 +++--- 5 files changed, 9 insertions(+), 11 deletions(-) diff --git a/dotnet/src/Agents/AzureAI/AzureAIAgent.cs b/dotnet/src/Agents/AzureAI/AzureAIAgent.cs index 8052a9969af2..800ae0427a1d 100644 --- a/dotnet/src/Agents/AzureAI/AzureAIAgent.cs +++ b/dotnet/src/Agents/AzureAI/AzureAIAgent.cs @@ -1,8 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. -using System; using System.Collections.Generic; -using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; using Azure.AI.Projects; @@ -168,7 +166,7 @@ public async Task> Invok await thread.StartThreadAsync(cancellationToken).ConfigureAwait(false); } - // Notify the thread that a new message is availble. + // Notify the thread that a new message is available. await thread.OnNewMessageAsync(message, cancellationToken).ConfigureAwait(false); // Create options that include the additional instructions. diff --git a/dotnet/src/Agents/AzureAI/AzureAIAgentThread.cs b/dotnet/src/Agents/AzureAI/AzureAIAgentThread.cs index 25a95f3bc3e6..9fd1007b1b9a 100644 --- a/dotnet/src/Agents/AzureAI/AzureAIAgentThread.cs +++ b/dotnet/src/Agents/AzureAI/AzureAIAgentThread.cs @@ -57,11 +57,11 @@ public override async Task StartThreadAsync(CancellationToken cancellati throw new InvalidOperationException("You cannot start this thread, since the thread is already active."); } - var assitantThreadResponse = await this._client.CreateThreadAsync(cancellationToken: cancellationToken).ConfigureAwait(false); - this._threadId = assitantThreadResponse.Value.Id; + var assistantThreadResponse = await this._client.CreateThreadAsync(cancellationToken: cancellationToken).ConfigureAwait(false); + this._threadId = assistantThreadResponse.Value.Id; this._isActive = true; - return assitantThreadResponse.Value.Id; + return assistantThreadResponse.Value.Id; } /// diff --git a/dotnet/src/Agents/Core/ChatCompletionAgent.cs b/dotnet/src/Agents/Core/ChatCompletionAgent.cs index ded6cbdd8f67..82b6a2368751 100644 --- a/dotnet/src/Agents/Core/ChatCompletionAgent.cs +++ b/dotnet/src/Agents/Core/ChatCompletionAgent.cs @@ -83,7 +83,7 @@ public async Task> Invok await thread.StartThreadAsync(cancellationToken).ConfigureAwait(false); } - // Notify the thread that a new message is availble and get the updated chat history. + // Notify the thread that a new message is available and get the updated chat history. await thread.OnNewMessageAsync(message, cancellationToken).ConfigureAwait(false); var chatHistory = await chatHistoryAgentThread.RetrieveCurrentChatHistoryAsync(cancellationToken).ConfigureAwait(false); diff --git a/dotnet/src/Agents/OpenAI/OpenAIAssistantAgent.cs b/dotnet/src/Agents/OpenAI/OpenAIAssistantAgent.cs index 82bb280a4d15..c42ceb18fecd 100644 --- a/dotnet/src/Agents/OpenAI/OpenAIAssistantAgent.cs +++ b/dotnet/src/Agents/OpenAI/OpenAIAssistantAgent.cs @@ -386,7 +386,7 @@ public async Task> Invok await thread.StartThreadAsync(cancellationToken).ConfigureAwait(false); } - // Notify the thread that a new message is availble. + // Notify the thread that a new message is available. await thread.OnNewMessageAsync(message, cancellationToken).ConfigureAwait(false); // Create options that include the additional instructions. diff --git a/dotnet/src/Agents/OpenAI/OpenAIAssistantAgentThread.cs b/dotnet/src/Agents/OpenAI/OpenAIAssistantAgentThread.cs index 336c0e53f37a..9494ea94e9a9 100644 --- a/dotnet/src/Agents/OpenAI/OpenAIAssistantAgentThread.cs +++ b/dotnet/src/Agents/OpenAI/OpenAIAssistantAgentThread.cs @@ -57,11 +57,11 @@ public override async Task StartThreadAsync(CancellationToken cancellati throw new InvalidOperationException("You cannot start this thread, since the thread is already active."); } - var assitantThreadResponse = await this._client.CreateThreadAsync(cancellationToken: cancellationToken).ConfigureAwait(false); - this._threadId = assitantThreadResponse.Value.Id; + var assistantThreadResponse = await this._client.CreateThreadAsync(cancellationToken: cancellationToken).ConfigureAwait(false); + this._threadId = assistantThreadResponse.Value.Id; this._isActive = true; - return assitantThreadResponse.Value.Id; + return assistantThreadResponse.Value.Id; } /// From af82b818d64e096cba0f5f4f60fa8260b5ea5c60 Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Wed, 19 Mar 2025 21:54:03 +0000 Subject: [PATCH 03/10] Fix ambiguous reference in xml docs --- .../samples/Concepts/Agents/ChatCompletion_ServiceSelection.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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) { From 3e0802f2a9b1c85433bdeec8a46d25cbe6722886 Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Thu, 20 Mar 2025 20:13:07 +0000 Subject: [PATCH 04/10] Adding InvokeAsync signature to Agent class and updating it with new options and new response type. --- dotnet/src/Agents/Abstractions/Agent.cs | 21 ++++++++++ .../Agents/Abstractions/AgentInvokeOptions.cs | 15 +++++++ .../AgentInvokeResponseAsyncEnumerable.cs | 39 ------------------- .../Agents/Abstractions/AgentResponseItem.cs | 36 +++++++++++++++++ .../Agents/Abstractions/AggregatorAgent.cs | 13 +++++++ .../IAgentInvokeResponseAsyncEnumerable.cs | 17 -------- dotnet/src/Agents/AzureAI/AzureAIAgent.cs | 18 ++++----- dotnet/src/Agents/Bedrock/BedrockAgent.cs | 13 +++++++ dotnet/src/Agents/Core/ChatCompletionAgent.cs | 13 +++---- .../src/Agents/OpenAI/OpenAIAssistantAgent.cs | 31 ++++++++++----- dotnet/src/Agents/UnitTests/MockAgent.cs | 12 ++++++ 11 files changed, 147 insertions(+), 81 deletions(-) create mode 100644 dotnet/src/Agents/Abstractions/AgentInvokeOptions.cs delete mode 100644 dotnet/src/Agents/Abstractions/AgentInvokeResponseAsyncEnumerable.cs create mode 100644 dotnet/src/Agents/Abstractions/AgentResponseItem.cs delete mode 100644 dotnet/src/Agents/Abstractions/IAgentInvokeResponseAsyncEnumerable.cs diff --git a/dotnet/src/Agents/Abstractions/Agent.cs b/dotnet/src/Agents/Abstractions/Agent.cs index 383b5df27385..1b78385e1ff0 100644 --- a/dotnet/src/Agents/Abstractions/Agent.cs +++ b/dotnet/src/Agents/Abstractions/Agent.cs @@ -43,6 +43,27 @@ 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); + /// /// The associated with this . /// 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/AgentInvokeResponseAsyncEnumerable.cs b/dotnet/src/Agents/Abstractions/AgentInvokeResponseAsyncEnumerable.cs deleted file mode 100644 index 99b74705f7c2..000000000000 --- a/dotnet/src/Agents/Abstractions/AgentInvokeResponseAsyncEnumerable.cs +++ /dev/null @@ -1,39 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Collections.Generic; -using System.Threading; - -namespace Microsoft.SemanticKernel.Agents; - -/// -/// Represents a response from an agent invocation. -/// -/// The type of data returned by the response. -public class AgentInvokeResponseAsyncEnumerable : IAgentInvokeResponseAsyncEnumerable -{ - private readonly IAsyncEnumerable _asyncEnumerable; - private readonly AgentThread _thread; - - /// - /// Initializes a new instance of the class. - /// - /// The internal that will be combined with additional invoke specific response information. - /// The conversation thread associated with the response. - public AgentInvokeResponseAsyncEnumerable(IAsyncEnumerable asyncEnumerable, AgentThread thread) - { - Verify.NotNull(asyncEnumerable); - Verify.NotNull(thread); - - this._asyncEnumerable = asyncEnumerable; - this._thread = thread; - } - - /// - public AgentThread Thread => this._thread; - - /// - public IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default) - { - return this._asyncEnumerable.GetAsyncEnumerator(cancellationToken); - } -} 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/AggregatorAgent.cs b/dotnet/src/Agents/Abstractions/AggregatorAgent.cs index 8cde6b5a9001..57e6036304c5 100644 --- a/dotnet/src/Agents/Abstractions/AggregatorAgent.cs +++ b/dotnet/src/Agents/Abstractions/AggregatorAgent.cs @@ -44,6 +44,19 @@ 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 corrrect approach here. + throw new NotImplementedException(); + } + /// /// /// Different instances will never share the same channel. diff --git a/dotnet/src/Agents/Abstractions/IAgentInvokeResponseAsyncEnumerable.cs b/dotnet/src/Agents/Abstractions/IAgentInvokeResponseAsyncEnumerable.cs deleted file mode 100644 index ba1775289830..000000000000 --- a/dotnet/src/Agents/Abstractions/IAgentInvokeResponseAsyncEnumerable.cs +++ /dev/null @@ -1,17 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Collections.Generic; - -namespace Microsoft.SemanticKernel.Agents; - -/// -/// Represents a response from an agent invocation. -/// -/// The type of data returned by the response. -public interface IAgentInvokeResponseAsyncEnumerable : IAsyncEnumerable -{ - /// - /// Gets the thread associated with this response. - /// - AgentThread Thread { get; } -} diff --git a/dotnet/src/Agents/AzureAI/AzureAIAgent.cs b/dotnet/src/Agents/AzureAI/AzureAIAgent.cs index 800ae0427a1d..e0763e129e8d 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; @@ -141,13 +142,13 @@ public IAsyncEnumerable InvokeAsync( } /// - public async Task> InvokeAsync( + public override async IAsyncEnumerable> InvokeAsync( ChatMessageContent message, AgentThread? thread = null, KernelArguments? arguments = null, Kernel? kernel = null, - string? additionalInstructions = null, - CancellationToken cancellationToken = default) + AgentInvokeOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) { Verify.NotNull(message); @@ -170,21 +171,20 @@ public async Task> Invok await thread.OnNewMessageAsync(message, cancellationToken).ConfigureAwait(false); // Create options that include the additional instructions. - var options = string.IsNullOrWhiteSpace(additionalInstructions) ? null : new AzureAIInvocationOptions() + var internalOptions = string.IsNullOrWhiteSpace(options?.AdditionalInstructions) ? null : new AzureAIInvocationOptions() { - AdditionalInstructions = additionalInstructions, + AdditionalInstructions = options?.AdditionalInstructions, }; // Invoke the Agent with the thread that we already added our message to. - var invokeResults = this.InvokeAsync(thread.ThreadId!, options, arguments, kernel, cancellationToken); + var invokeResults = this.InvokeAsync(thread.ThreadId!, internalOptions, arguments, kernel, cancellationToken); - // Notify the thread of any new messages returned by the agent. + // Notify the thread of new messages and return them to the caller. await foreach (var result in invokeResults.ConfigureAwait(false)) { await thread.OnNewMessageAsync(result, cancellationToken).ConfigureAwait(false); + yield return new(result, thread); } - - return new AgentInvokeResponseAsyncEnumerable(invokeResults, thread); } /// diff --git a/dotnet/src/Agents/Bedrock/BedrockAgent.cs b/dotnet/src/Agents/Bedrock/BedrockAgent.cs index a8b3a2b9f4fa..bcc44cadda29 100644 --- a/dotnet/src/Agents/Bedrock/BedrockAgent.cs +++ b/dotnet/src/Agents/Bedrock/BedrockAgent.cs @@ -73,6 +73,19 @@ 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(); + } + /// /// 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 82b6a2368751..268da1c63a42 100644 --- a/dotnet/src/Agents/Core/ChatCompletionAgent.cs +++ b/dotnet/src/Agents/Core/ChatCompletionAgent.cs @@ -58,13 +58,13 @@ public ChatCompletionAgent( public AuthorRole InstructionsRole { get; init; } = AuthorRole.System; /// - public async Task> InvokeAsync( + public override async IAsyncEnumerable> InvokeAsync( ChatMessageContent message, AgentThread? thread = null, KernelArguments? arguments = null, Kernel? kernel = null, - string? additionalInstructions = null, - CancellationToken cancellationToken = default) + AgentInvokeOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) { Verify.NotNull(message); @@ -89,15 +89,14 @@ public async Task> Invok // Invoke Chat Completion with the updated chat history. string agentName = this.GetDisplayName(); - var invokeResults = this.InternalInvokeAsync(agentName, chatHistory, arguments, kernel, additionalInstructions, cancellationToken); + var invokeResults = this.InternalInvokeAsync(agentName, chatHistory, arguments, kernel, options?.AdditionalInstructions, cancellationToken); - // Notify the thread of any new messages returned by chat completion. + // Notify the thread of new messages and return them to the caller. await foreach (var result in invokeResults.ConfigureAwait(false)) { await thread.OnNewMessageAsync(result, cancellationToken).ConfigureAwait(false); + yield return new(result, thread); } - - return new AgentInvokeResponseAsyncEnumerable(invokeResults, thread); } /// diff --git a/dotnet/src/Agents/OpenAI/OpenAIAssistantAgent.cs b/dotnet/src/Agents/OpenAI/OpenAIAssistantAgent.cs index c42ceb18fecd..54d6e2b0e75f 100644 --- a/dotnet/src/Agents/OpenAI/OpenAIAssistantAgent.cs +++ b/dotnet/src/Agents/OpenAI/OpenAIAssistantAgent.cs @@ -361,13 +361,13 @@ public async Task DeleteAsync(CancellationToken cancellationToken = defaul } /// - public async Task> InvokeAsync( + public override async IAsyncEnumerable> InvokeAsync( ChatMessageContent message, AgentThread? thread = null, KernelArguments? arguments = null, Kernel? kernel = null, - string? additionalInstructions = null, - CancellationToken cancellationToken = default) + AgentInvokeOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) { Verify.NotNull(message); @@ -390,21 +390,23 @@ public async Task> Invok await thread.OnNewMessageAsync(message, cancellationToken).ConfigureAwait(false); // Create options that include the additional instructions. - var options = string.IsNullOrWhiteSpace(additionalInstructions) ? null : new RunCreationOptions() + var internalOptions = string.IsNullOrWhiteSpace(options?.AdditionalInstructions) ? null : new RunCreationOptions() { - AdditionalInstructions = additionalInstructions, + AdditionalInstructions = options?.AdditionalInstructions, }; // Invoke the Agent with the thread that we already added our message to. - var invokeResults = this.InvokeAsync(thread.ThreadId!, options, arguments, kernel, cancellationToken); + var invokeResults = this.InvokeAsync(thread.ThreadId!, internalOptions, arguments, kernel, cancellationToken); - // Notify the thread of any new messages returned by the agent. + // Process messages in the background. + var processNewMessagesTask = ProcessNewMessagesAsync(thread, invokeResults, cancellationToken); + + // Notify the thread of new messages and return them to the caller. await foreach (var result in invokeResults.ConfigureAwait(false)) { await thread.OnNewMessageAsync(result, cancellationToken).ConfigureAwait(false); + yield return new(result, thread); } - - return new AgentInvokeResponseAsyncEnumerable(invokeResults, thread); } /// @@ -601,4 +603,15 @@ private static OpenAIAssistantDefinition CreateAssistantDefinition(Assistant mod ExecutionOptions = options, }; } + + private static async Task ProcessNewMessagesAsync(AgentThread thread, IAsyncEnumerable invokeResults, CancellationToken cancellationToken) + { + // Notify the thread of any new messages returned by the agent. + await foreach (var result in invokeResults.ConfigureAwait(false)) + { + await thread.OnNewMessageAsync(result, cancellationToken).ConfigureAwait(false); + } + + return thread; + } } diff --git a/dotnet/src/Agents/UnitTests/MockAgent.cs b/dotnet/src/Agents/UnitTests/MockAgent.cs index 7f242ff510a5..8a580cd376b4 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, From b5448798a26082c6f898d7777f18b9190ade3df5 Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Fri, 21 Mar 2025 08:56:03 +0000 Subject: [PATCH 05/10] Naming update related to PR feedback --- dotnet/src/Agents/Abstractions/AgentThread.cs | 6 ++-- dotnet/src/Agents/AzureAI/AzureAIAgent.cs | 4 +-- .../src/Agents/AzureAI/AzureAIAgentThread.cs | 28 ++++++++--------- dotnet/src/Agents/Core/ChatCompletionAgent.cs | 2 +- .../src/Agents/Core/ChatHistoryAgentThread.cs | 27 ++++++++++++----- .../src/Agents/OpenAI/OpenAIAssistantAgent.cs | 4 +-- .../OpenAI/OpenAIAssistantAgentThread.cs | 30 +++++++++---------- 7 files changed, 57 insertions(+), 44 deletions(-) diff --git a/dotnet/src/Agents/Abstractions/AgentThread.cs b/dotnet/src/Agents/Abstractions/AgentThread.cs index e718cb941e8b..c700642de18a 100644 --- a/dotnet/src/Agents/Abstractions/AgentThread.cs +++ b/dotnet/src/Agents/Abstractions/AgentThread.cs @@ -23,21 +23,21 @@ public abstract class AgentThread /// /// Gets the id of the current thread. /// - public abstract string? ThreadId { get; } + public abstract string? Id { get; } /// /// Starts 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 StartThreadAsync(CancellationToken cancellationToken = default); + public abstract Task StartAsync(CancellationToken cancellationToken = default); /// /// Ends 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 EndThreadAsync(CancellationToken cancellationToken = default); + public abstract Task EndAsync(CancellationToken cancellationToken = default); /// /// This method is called when a new message has been contributed to the chat by any participant. diff --git a/dotnet/src/Agents/AzureAI/AzureAIAgent.cs b/dotnet/src/Agents/AzureAI/AzureAIAgent.cs index e0763e129e8d..d7a1b4494406 100644 --- a/dotnet/src/Agents/AzureAI/AzureAIAgent.cs +++ b/dotnet/src/Agents/AzureAI/AzureAIAgent.cs @@ -164,7 +164,7 @@ public override async IAsyncEnumerable> In if (!thread.IsActive) { - await thread.StartThreadAsync(cancellationToken).ConfigureAwait(false); + await thread.StartAsync(cancellationToken).ConfigureAwait(false); } // Notify the thread that a new message is available. @@ -177,7 +177,7 @@ public override async IAsyncEnumerable> In }; // Invoke the Agent with the thread that we already added our message to. - var invokeResults = this.InvokeAsync(thread.ThreadId!, internalOptions, arguments, kernel, cancellationToken); + var invokeResults = this.InvokeAsync(thread.Id!, internalOptions, arguments, kernel, cancellationToken); // Notify the thread of new messages and return them to the caller. await foreach (var result in invokeResults.ConfigureAwait(false)) diff --git a/dotnet/src/Agents/AzureAI/AzureAIAgentThread.cs b/dotnet/src/Agents/AzureAI/AzureAIAgentThread.cs index 9fd1007b1b9a..cf3e805b77e5 100644 --- a/dotnet/src/Agents/AzureAI/AzureAIAgentThread.cs +++ b/dotnet/src/Agents/AzureAI/AzureAIAgentThread.cs @@ -15,7 +15,7 @@ public class AzureAIAgentThread : AgentThread { private readonly AgentsClient _client; private bool _isActive = false; - private string? _threadId = null; + private string? _id = null; /// /// Initializes a new instance of the class. @@ -29,28 +29,28 @@ public AzureAIAgentThread(AgentsClient client) } /// - /// Initializes a new instance of the class. + /// 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 threadId) + /// The ID of an existing thread to resume. + public AzureAIAgentThread(AgentsClient client, string id) { Verify.NotNull(client); - Verify.NotNull(threadId); + Verify.NotNull(id); this._client = client; this._isActive = true; - this._threadId = threadId; + this._id = id; } /// public override bool IsActive => this._isActive; /// - public override string? ThreadId => this._threadId; + public override string? Id => this._id; /// - public override async Task StartThreadAsync(CancellationToken cancellationToken = default) + public override async Task StartAsync(CancellationToken cancellationToken = default) { if (this._isActive) { @@ -58,23 +58,23 @@ public override async Task StartThreadAsync(CancellationToken cancellati } var assistantThreadResponse = await this._client.CreateThreadAsync(cancellationToken: cancellationToken).ConfigureAwait(false); - this._threadId = assistantThreadResponse.Value.Id; + this._id = assistantThreadResponse.Value.Id; this._isActive = true; return assistantThreadResponse.Value.Id; } /// - public override async Task EndThreadAsync(CancellationToken cancellationToken = default) + public override async Task EndAsync(CancellationToken cancellationToken = default) { if (!this._isActive) { throw new InvalidOperationException("This thread cannot be ended, since the thread is not currently active."); } - await this._client.DeleteThreadAsync(this._threadId, cancellationToken).ConfigureAwait(false); + await this._client.DeleteThreadAsync(this._id, cancellationToken).ConfigureAwait(false); this._isActive = false; - this._threadId = null; + this._id = null; } /// @@ -86,9 +86,9 @@ public override async Task OnNewMessageAsync(ChatMessageContent newMessage, Canc } // 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._threadId)) + if (newMessage.Metadata == null || !newMessage.Metadata.TryGetValue("ThreadId", out var messageThreadId) || !string.Equals(messageThreadId, this._id)) { - await AgentThreadActions.CreateMessageAsync(this._client, this._threadId!, newMessage, cancellationToken).ConfigureAwait(false); + await AgentThreadActions.CreateMessageAsync(this._client, this._id!, newMessage, cancellationToken).ConfigureAwait(false); } } } diff --git a/dotnet/src/Agents/Core/ChatCompletionAgent.cs b/dotnet/src/Agents/Core/ChatCompletionAgent.cs index 268da1c63a42..ad27055d45a9 100644 --- a/dotnet/src/Agents/Core/ChatCompletionAgent.cs +++ b/dotnet/src/Agents/Core/ChatCompletionAgent.cs @@ -80,7 +80,7 @@ public override async IAsyncEnumerable> In if (!thread.IsActive) { - await thread.StartThreadAsync(cancellationToken).ConfigureAwait(false); + await thread.StartAsync(cancellationToken).ConfigureAwait(false); } // Notify the thread that a new message is available and get the updated chat history. diff --git a/dotnet/src/Agents/Core/ChatHistoryAgentThread.cs b/dotnet/src/Agents/Core/ChatHistoryAgentThread.cs index c0c59925faa6..bfd8b94e0695 100644 --- a/dotnet/src/Agents/Core/ChatHistoryAgentThread.cs +++ b/dotnet/src/Agents/Core/ChatHistoryAgentThread.cs @@ -14,7 +14,7 @@ public class ChatHistoryAgentThread : AgentThread { private readonly ChatHistory _chatHistory = new(); private bool _isActive = false; - private string? _threadId = null; + private string? _id = null; /// /// Initializes a new instance of the class. @@ -23,28 +23,41 @@ 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._isActive = true; + this._id = id ?? Guid.NewGuid().ToString("N"); + } + /// public override bool IsActive => this._isActive; /// - public override string? ThreadId => this._threadId; + public override string? Id => this._id; /// - public override Task StartThreadAsync(CancellationToken cancellationToken = default) + public override Task StartAsync(CancellationToken cancellationToken = default) { if (this._isActive) { throw new InvalidOperationException("You cannot start this thread, since the thread is already active."); } - this._threadId = Guid.NewGuid().ToString("N"); + this._id = Guid.NewGuid().ToString("N"); this._isActive = true; - return Task.FromResult(this._threadId); + return Task.FromResult(this._id); } /// - public override Task EndThreadAsync(CancellationToken cancellationToken = default) + public override Task EndAsync(CancellationToken cancellationToken = default) { if (!this._isActive) { @@ -52,7 +65,7 @@ public override Task EndThreadAsync(CancellationToken cancellationToken = defaul } this._chatHistory.Clear(); - this._threadId = null; + this._id = null; this._isActive = false; return Task.CompletedTask; diff --git a/dotnet/src/Agents/OpenAI/OpenAIAssistantAgent.cs b/dotnet/src/Agents/OpenAI/OpenAIAssistantAgent.cs index 54d6e2b0e75f..cb67870d5064 100644 --- a/dotnet/src/Agents/OpenAI/OpenAIAssistantAgent.cs +++ b/dotnet/src/Agents/OpenAI/OpenAIAssistantAgent.cs @@ -383,7 +383,7 @@ public override async IAsyncEnumerable> In if (!thread.IsActive) { - await thread.StartThreadAsync(cancellationToken).ConfigureAwait(false); + await thread.StartAsync(cancellationToken).ConfigureAwait(false); } // Notify the thread that a new message is available. @@ -396,7 +396,7 @@ public override async IAsyncEnumerable> In }; // Invoke the Agent with the thread that we already added our message to. - var invokeResults = this.InvokeAsync(thread.ThreadId!, internalOptions, arguments, kernel, cancellationToken); + var invokeResults = this.InvokeAsync(thread.Id!, internalOptions, arguments, kernel, cancellationToken); // Process messages in the background. var processNewMessagesTask = ProcessNewMessagesAsync(thread, invokeResults, cancellationToken); diff --git a/dotnet/src/Agents/OpenAI/OpenAIAssistantAgentThread.cs b/dotnet/src/Agents/OpenAI/OpenAIAssistantAgentThread.cs index 9494ea94e9a9..18a9af0149a5 100644 --- a/dotnet/src/Agents/OpenAI/OpenAIAssistantAgentThread.cs +++ b/dotnet/src/Agents/OpenAI/OpenAIAssistantAgentThread.cs @@ -15,7 +15,7 @@ public class OpenAIAssistantAgentThread : AgentThread { private readonly AssistantClient _client; private bool _isActive = false; - private string? _threadId = null; + private string? _id = null; /// /// Initializes a new instance of the class. @@ -29,28 +29,28 @@ public OpenAIAssistantAgentThread(AssistantClient client) } /// - /// Initializes a new instance of the class. + /// 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 threadId) + /// The ID of an existing thread to resume. + public OpenAIAssistantAgentThread(AssistantClient client, string id) { Verify.NotNull(client); - Verify.NotNull(threadId); + Verify.NotNull(id); this._client = client; this._isActive = true; - this._threadId = threadId; + this._id = id; } /// public override bool IsActive => this._isActive; /// - public override string? ThreadId => this._threadId; + public override string? Id => this._id; /// - public override async Task StartThreadAsync(CancellationToken cancellationToken = default) + public override async Task StartAsync(CancellationToken cancellationToken = default) { if (this._isActive) { @@ -58,23 +58,23 @@ public override async Task StartThreadAsync(CancellationToken cancellati } var assistantThreadResponse = await this._client.CreateThreadAsync(cancellationToken: cancellationToken).ConfigureAwait(false); - this._threadId = assistantThreadResponse.Value.Id; + this._id = assistantThreadResponse.Value.Id; this._isActive = true; return assistantThreadResponse.Value.Id; } /// - public override async Task EndThreadAsync(CancellationToken cancellationToken = default) + public override async Task EndAsync(CancellationToken cancellationToken = default) { if (!this._isActive) { throw new InvalidOperationException("This thread cannot be ended, since the thread is not currently active."); } - await this._client.DeleteThreadAsync(this._threadId, cancellationToken).ConfigureAwait(false); - this._threadId = null; - this._threadId = null; + await this._client.DeleteThreadAsync(this._id, cancellationToken).ConfigureAwait(false); + this._id = null; + this._id = null; } /// @@ -86,9 +86,9 @@ public override async Task OnNewMessageAsync(ChatMessageContent newMessage, Canc } // 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._threadId)) + if (newMessage.Metadata == null || !newMessage.Metadata.TryGetValue("ThreadId", out var messageThreadId) || !string.Equals(messageThreadId, this._id)) { - await AssistantThreadActions.CreateMessageAsync(this._client, this._threadId!, newMessage, cancellationToken).ConfigureAwait(false); + await AssistantThreadActions.CreateMessageAsync(this._client, this._id!, newMessage, cancellationToken).ConfigureAwait(false); } } } From cc99661685d8395e106b2e45e19324b671e3e255 Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Fri, 21 Mar 2025 11:13:37 +0000 Subject: [PATCH 06/10] Add streaming method and implementations Fix kernel and arguments override capability Add helper for common thread validation --- dotnet/src/Agents/Abstractions/Agent.cs | 60 ++++++++++++ .../Agents/Abstractions/AggregatorAgent.cs | 13 +++ dotnet/src/Agents/AzureAI/AzureAIAgent.cs | 79 ++++++++++++---- dotnet/src/Agents/Bedrock/BedrockAgent.cs | 13 +++ dotnet/src/Agents/Core/ChatCompletionAgent.cs | 87 ++++++++++++----- .../src/Agents/OpenAI/OpenAIAssistantAgent.cs | 93 ++++++++++++------- dotnet/src/Agents/UnitTests/MockAgent.cs | 13 +++ 7 files changed, 282 insertions(+), 76 deletions(-) diff --git a/dotnet/src/Agents/Abstractions/Agent.cs b/dotnet/src/Agents/Abstractions/Agent.cs index 1b78385e1ff0..6d99f2396811 100644 --- a/dotnet/src/Agents/Abstractions/Agent.cs +++ b/dotnet/src/Agents/Abstractions/Agent.cs @@ -64,6 +64,27 @@ public abstract IAsyncEnumerable> InvokeAs 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 . /// @@ -117,4 +138,43 @@ public abstract IAsyncEnumerable> InvokeAs 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 type of the agent. + /// 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)}."); + } + + if (!thread.IsActive) + { + await thread.StartAsync(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/AggregatorAgent.cs b/dotnet/src/Agents/Abstractions/AggregatorAgent.cs index 57e6036304c5..a081d54a1c36 100644 --- a/dotnet/src/Agents/Abstractions/AggregatorAgent.cs +++ b/dotnet/src/Agents/Abstractions/AggregatorAgent.cs @@ -57,6 +57,19 @@ public override IAsyncEnumerable> InvokeAs 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 corrrect 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 d7a1b4494406..b27edd358c43 100644 --- a/dotnet/src/Agents/AzureAI/AzureAIAgent.cs +++ b/dotnet/src/Agents/AzureAI/AzureAIAgent.cs @@ -152,23 +152,11 @@ public override async IAsyncEnumerable> In { Verify.NotNull(message); - if (thread is null) - { - thread = new AzureAIAgentThread(this.Client); - } - - if (thread is not AzureAIAgentThread) - { - throw new KernelException($"{nameof(AzureAIAgent)} currently only supports agent threads of type {nameof(AzureAIAgentThread)}."); - } - - if (!thread.IsActive) - { - await thread.StartAsync(cancellationToken).ConfigureAwait(false); - } - - // Notify the thread that a new message is available. - await thread.OnNewMessageAsync(message, cancellationToken).ConfigureAwait(false); + 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() @@ -177,13 +165,18 @@ public override async IAsyncEnumerable> In }; // Invoke the Agent with the thread that we already added our message to. - var invokeResults = this.InvokeAsync(thread.Id!, internalOptions, arguments, kernel, cancellationToken); + 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 thread.OnNewMessageAsync(result, cancellationToken).ConfigureAwait(false); - yield return new(result, thread); + await azureAIAgentThread.OnNewMessageAsync(result, cancellationToken).ConfigureAwait(false); + yield return new(result, azureAIAgentThread); } } @@ -226,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. /// diff --git a/dotnet/src/Agents/Bedrock/BedrockAgent.cs b/dotnet/src/Agents/Bedrock/BedrockAgent.cs index bcc44cadda29..d5cdf0e6255d 100644 --- a/dotnet/src/Agents/Bedrock/BedrockAgent.cs +++ b/dotnet/src/Agents/Bedrock/BedrockAgent.cs @@ -86,6 +86,19 @@ public override IAsyncEnumerable> InvokeAs 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 ad27055d45a9..2ad0051c19bb 100644 --- a/dotnet/src/Agents/Core/ChatCompletionAgent.cs +++ b/dotnet/src/Agents/Core/ChatCompletionAgent.cs @@ -68,34 +68,28 @@ public override async IAsyncEnumerable> In { Verify.NotNull(message); - if (thread == null) - { - thread = new ChatHistoryAgentThread(); - } - - if (thread is not ChatHistoryAgentThread chatHistoryAgentThread) - { - throw new KernelException($"{nameof(ChatCompletionAgent)} currently only supports agent threads of type {nameof(ChatHistoryAgentThread)}."); - } - - if (!thread.IsActive) - { - await thread.StartAsync(cancellationToken).ConfigureAwait(false); - } - - // Notify the thread that a new message is available and get the updated chat history. - await thread.OnNewMessageAsync(message, cancellationToken).ConfigureAwait(false); - var chatHistory = await chatHistoryAgentThread.RetrieveCurrentChatHistoryAsync(cancellationToken).ConfigureAwait(false); + var chatHistoryAgentThread = await this.EnsureThreadExistsWithMessageAsync( + message, + thread, + () => new ChatHistoryAgentThread(), + cancellationToken).ConfigureAwait(false); // Invoke Chat Completion with the updated chat history. + var chatHistory = await chatHistoryAgentThread.RetrieveCurrentChatHistoryAsync(cancellationToken).ConfigureAwait(false); string agentName = this.GetDisplayName(); - var invokeResults = this.InternalInvokeAsync(agentName, chatHistory, arguments, kernel, options?.AdditionalInstructions, cancellationToken); + 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 thread.OnNewMessageAsync(result, cancellationToken).ConfigureAwait(false); - yield return new(result, thread); + await chatHistoryAgentThread.OnNewMessageAsync(result, cancellationToken).ConfigureAwait(false); + yield return new(result, chatHistoryAgentThread); } } @@ -114,6 +108,41 @@ public override IAsyncEnumerable InvokeAsync( 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 = await chatHistoryAgentThread.RetrieveCurrentChatHistoryAsync(cancellationToken).ConfigureAwait(false); + 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); + } + } + /// public override IAsyncEnumerable InvokeStreamingAsync( ChatHistory history, @@ -125,7 +154,18 @@ public override IAsyncEnumerable InvokeStreamingAsy return ActivityExtensions.RunWithActivityAsync( () => ModelDiagnostics.StartAgentInvocationActivity(this.Id, agentName, this.Description), - () => this.InternalInvokeStreamingAsync(agentName, history, arguments, kernel, null, cancellationToken), + () => this.InternalInvokeStreamingAsync( + agentName, + history, + (newMessage) => + { + history.Add(newMessage); + return Task.CompletedTask; + }, + arguments, + kernel, + null, + cancellationToken), cancellationToken); } @@ -229,6 +269,7 @@ await chatCompletionService.GetChatMessageContentsAsync( private async IAsyncEnumerable InternalInvokeStreamingAsync( string agentName, ChatHistory history, + Func onNewMessage, KernelArguments? arguments = null, Kernel? kernel = null, string? additionalInstructions = null, @@ -276,12 +317,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/OpenAI/OpenAIAssistantAgent.cs b/dotnet/src/Agents/OpenAI/OpenAIAssistantAgent.cs index cb67870d5064..45852e9ff6db 100644 --- a/dotnet/src/Agents/OpenAI/OpenAIAssistantAgent.cs +++ b/dotnet/src/Agents/OpenAI/OpenAIAssistantAgent.cs @@ -371,23 +371,11 @@ public override async IAsyncEnumerable> In { Verify.NotNull(message); - if (thread is null) - { - thread = new OpenAIAssistantAgentThread(this.Client); - } - - if (thread is not OpenAIAssistantAgentThread) - { - throw new KernelException($"{nameof(OpenAIAssistantAgent)} currently only supports agent threads of type {nameof(OpenAIAssistantAgentThread)}."); - } - - if (!thread.IsActive) - { - await thread.StartAsync(cancellationToken).ConfigureAwait(false); - } - - // Notify the thread that a new message is available. - await thread.OnNewMessageAsync(message, cancellationToken).ConfigureAwait(false); + 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() @@ -396,16 +384,18 @@ public override async IAsyncEnumerable> In }; // Invoke the Agent with the thread that we already added our message to. - var invokeResults = this.InvokeAsync(thread.Id!, internalOptions, arguments, kernel, cancellationToken); - - // Process messages in the background. - var processNewMessagesTask = ProcessNewMessagesAsync(thread, invokeResults, cancellationToken); + 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 thread.OnNewMessageAsync(result, cancellationToken).ConfigureAwait(false); - yield return new(result, thread); + await openAIAssistantAgentThread.OnNewMessageAsync(result, cancellationToken).ConfigureAwait(false); + yield return new(result, openAIAssistantAgentThread); } } @@ -466,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. /// @@ -603,15 +639,4 @@ private static OpenAIAssistantDefinition CreateAssistantDefinition(Assistant mod ExecutionOptions = options, }; } - - private static async Task ProcessNewMessagesAsync(AgentThread thread, IAsyncEnumerable invokeResults, CancellationToken cancellationToken) - { - // Notify the thread of any new messages returned by the agent. - await foreach (var result in invokeResults.ConfigureAwait(false)) - { - await thread.OnNewMessageAsync(result, cancellationToken).ConfigureAwait(false); - } - - return thread; - } } diff --git a/dotnet/src/Agents/UnitTests/MockAgent.cs b/dotnet/src/Agents/UnitTests/MockAgent.cs index 8a580cd376b4..b88ba37a3aa1 100644 --- a/dotnet/src/Agents/UnitTests/MockAgent.cs +++ b/dotnet/src/Agents/UnitTests/MockAgent.cs @@ -42,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, From 68b5b60bbd4c00c0d0ac8e18084a83e1f8a1f358 Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Fri, 21 Mar 2025 16:04:07 +0000 Subject: [PATCH 07/10] Add integration tests for ChatCompletion and AzureOpenAIAssistant --- dotnet/src/Agents/Abstractions/Agent.cs | 1 - dotnet/src/Agents/AzureAI/AzureAIAgent.cs | 2 +- .../src/Agents/AzureAI/AzureAIAgentThread.cs | 12 +++ dotnet/src/Agents/AzureAI/AzureAIChannel.cs | 2 +- .../AzureAI/Internal/AgentThreadActions.cs | 5 +- dotnet/src/Agents/Core/ChatCompletionAgent.cs | 12 ++- .../src/Agents/Core/ChatHistoryAgentThread.cs | 6 +- .../OpenAI/Internal/AssistantThreadActions.cs | 5 +- .../src/Agents/OpenAI/OpenAIAssistantAgent.cs | 2 +- .../OpenAI/OpenAIAssistantAgentThread.cs | 14 ++- .../Agents/OpenAI/OpenAIAssistantChannel.cs | 2 +- .../AgentFixture.cs | 27 ++++++ .../ChatCompletionAgentFixture.cs | 72 +++++++++++++++ .../ChatCompletionAgentInvokeTests.cs | 7 ++ .../CommonInterfaceConformance/InvokeTests.cs | 89 +++++++++++++++++++ .../OpenAIAssistantAgentFixture.cs | 80 +++++++++++++++++ .../OpenAIAssistantAgentInvokeTests.cs | 7 ++ 17 files changed, 331 insertions(+), 14 deletions(-) create mode 100644 dotnet/src/IntegrationTests/Agents/CommonInterfaceConformance/AgentFixture.cs create mode 100644 dotnet/src/IntegrationTests/Agents/CommonInterfaceConformance/ChatCompletionAgentFixture.cs create mode 100644 dotnet/src/IntegrationTests/Agents/CommonInterfaceConformance/ChatCompletionAgentInvokeTests.cs create mode 100644 dotnet/src/IntegrationTests/Agents/CommonInterfaceConformance/InvokeTests.cs create mode 100644 dotnet/src/IntegrationTests/Agents/CommonInterfaceConformance/OpenAIAssistantAgentFixture.cs create mode 100644 dotnet/src/IntegrationTests/Agents/CommonInterfaceConformance/OpenAIAssistantAgentInvokeTests.cs diff --git a/dotnet/src/Agents/Abstractions/Agent.cs b/dotnet/src/Agents/Abstractions/Agent.cs index 6d99f2396811..d5f8c3f631f2 100644 --- a/dotnet/src/Agents/Abstractions/Agent.cs +++ b/dotnet/src/Agents/Abstractions/Agent.cs @@ -143,7 +143,6 @@ public abstract IAsyncEnumerable> /// 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 type of the agent. /// 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. diff --git a/dotnet/src/Agents/AzureAI/AzureAIAgent.cs b/dotnet/src/Agents/AzureAI/AzureAIAgent.cs index b27edd358c43..0ee773037885 100644 --- a/dotnet/src/Agents/AzureAI/AzureAIAgent.cs +++ b/dotnet/src/Agents/AzureAI/AzureAIAgent.cs @@ -118,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); } /// diff --git a/dotnet/src/Agents/AzureAI/AzureAIAgentThread.cs b/dotnet/src/Agents/AzureAI/AzureAIAgentThread.cs index cf3e805b77e5..cfde3d6da8ea 100644 --- a/dotnet/src/Agents/AzureAI/AzureAIAgentThread.cs +++ b/dotnet/src/Agents/AzureAI/AzureAIAgentThread.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using Azure.AI.Projects; @@ -91,4 +92,15 @@ public override async Task OnNewMessageAsync(ChatMessageContent newMessage, Canc await AgentThreadActions.CreateMessageAsync(this._client, this._id!, newMessage, cancellationToken).ConfigureAwait(false); } } + + /// + public IAsyncEnumerable GetMessagesAsync(CancellationToken cancellationToken = default) + { + if (!this._isActive) + { + throw new InvalidOperationException("The messages for this thread cannot be retrieved, since the thread is not currently active."); + } + + 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 70cb5bffa97a..9eb6495b68f8 100644 --- a/dotnet/src/Agents/AzureAI/Internal/AgentThreadActions.cs +++ b/dotnet/src/Agents/AzureAI/Internal/AgentThreadActions.cs @@ -86,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 @@ -96,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/Core/ChatCompletionAgent.cs b/dotnet/src/Agents/Core/ChatCompletionAgent.cs index 2ad0051c19bb..ddbe0bb5ac5c 100644 --- a/dotnet/src/Agents/Core/ChatCompletionAgent.cs +++ b/dotnet/src/Agents/Core/ChatCompletionAgent.cs @@ -75,7 +75,11 @@ public override async IAsyncEnumerable> In cancellationToken).ConfigureAwait(false); // Invoke Chat Completion with the updated chat history. - var chatHistory = await chatHistoryAgentThread.RetrieveCurrentChatHistoryAsync(cancellationToken).ConfigureAwait(false); + 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, @@ -126,7 +130,11 @@ public override async IAsyncEnumerable - public Task RetrieveCurrentChatHistoryAsync(CancellationToken cancellationToken = default) + public IAsyncEnumerable GetMessagesAsync(CancellationToken cancellationToken = default) { if (!this._isActive) { throw new InvalidOperationException("The chat history for this thread cannot be retrieved, since the thread is not currently active."); } - return Task.FromResult(this._chatHistory); + 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 45852e9ff6db..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); } /// diff --git a/dotnet/src/Agents/OpenAI/OpenAIAssistantAgentThread.cs b/dotnet/src/Agents/OpenAI/OpenAIAssistantAgentThread.cs index 18a9af0149a5..67c46a616c12 100644 --- a/dotnet/src/Agents/OpenAI/OpenAIAssistantAgentThread.cs +++ b/dotnet/src/Agents/OpenAI/OpenAIAssistantAgentThread.cs @@ -1,6 +1,7 @@ // 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; @@ -73,7 +74,7 @@ public override async Task EndAsync(CancellationToken cancellationToken = defaul } await this._client.DeleteThreadAsync(this._id, cancellationToken).ConfigureAwait(false); - this._id = null; + this._isActive = false; this._id = null; } @@ -91,4 +92,15 @@ public override async Task OnNewMessageAsync(ChatMessageContent newMessage, Canc await AssistantThreadActions.CreateMessageAsync(this._client, this._id!, newMessage, cancellationToken).ConfigureAwait(false); } } + + /// + public IAsyncEnumerable GetMessagesAsync(CancellationToken cancellationToken = default) + { + if (!this._isActive) + { + throw new InvalidOperationException("The messages for this thread cannot be retrieved, since the thread is not currently active."); + } + + 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/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/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..ba143e72cb4b --- /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!.IsActive) + { + 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()) +{ +} From 04a6f76d7eeb1bdf974c1b6a5461f2ad1aedc93b Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Fri, 21 Mar 2025 17:17:01 +0000 Subject: [PATCH 08/10] Add integration tests for Azure AI Agent. --- .../AzureAIAgentFixture.cs | 77 +++++++++++++++++++ .../AzureAIAgentInvokeTests.cs | 7 ++ .../IntegrationTests/IntegrationTests.csproj | 1 + .../TestSettings/AzureAIConfiguration.cs | 14 ++++ 4 files changed, 99 insertions(+) create mode 100644 dotnet/src/IntegrationTests/Agents/CommonInterfaceConformance/AzureAIAgentFixture.cs create mode 100644 dotnet/src/IntegrationTests/Agents/CommonInterfaceConformance/AzureAIAgentInvokeTests.cs create mode 100644 dotnet/src/IntegrationTests/TestSettings/AzureAIConfiguration.cs diff --git a/dotnet/src/IntegrationTests/Agents/CommonInterfaceConformance/AzureAIAgentFixture.cs b/dotnet/src/IntegrationTests/Agents/CommonInterfaceConformance/AzureAIAgentFixture.cs new file mode 100644 index 000000000000..7680918f521e --- /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!.IsActive) + { + 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/IntegrationTests.csproj b/dotnet/src/IntegrationTests/IntegrationTests.csproj index 9f4e835df255..a5ea9e436469 100644 --- a/dotnet/src/IntegrationTests/IntegrationTests.csproj +++ b/dotnet/src/IntegrationTests/IntegrationTests.csproj @@ -68,6 +68,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; +} From baba29a1febd4b6bc554b465105e0831bbbb734b Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Fri, 21 Mar 2025 19:14:00 +0000 Subject: [PATCH 09/10] Remove IsActive and rename Start and End --- dotnet/src/Agents/Abstractions/Agent.cs | 5 +--- dotnet/src/Agents/Abstractions/AgentThread.cs | 13 +++------ .../src/Agents/AzureAI/AzureAIAgentThread.cs | 27 +++++++------------ .../src/Agents/Core/ChatHistoryAgentThread.cs | 27 +++++++------------ .../OpenAI/OpenAIAssistantAgentThread.cs | 27 +++++++------------ .../AzureAIAgentFixture.cs | 2 +- .../OpenAIAssistantAgentFixture.cs | 2 +- 7 files changed, 37 insertions(+), 66 deletions(-) diff --git a/dotnet/src/Agents/Abstractions/Agent.cs b/dotnet/src/Agents/Abstractions/Agent.cs index d5f8c3f631f2..a742d7ce6767 100644 --- a/dotnet/src/Agents/Abstractions/Agent.cs +++ b/dotnet/src/Agents/Abstractions/Agent.cs @@ -166,10 +166,7 @@ protected async Task EnsureThreadExistsWithMessageAsync public abstract class AgentThread { - /// - /// Gets a value indicating whether the thread is currently active. - /// - public abstract bool IsActive { get; } - /// /// Gets the id of the current thread. /// public abstract string? Id { get; } /// - /// Starts the thread and returns the thread id. + /// 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 StartAsync(CancellationToken cancellationToken = default); + public abstract Task CreateAsync(CancellationToken cancellationToken = default); /// - /// Ends the current thread. + /// 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 EndAsync(CancellationToken cancellationToken = default); + public abstract Task DeleteAsync(CancellationToken cancellationToken = default); /// /// This method is called when a new message has been contributed to the chat by any participant. diff --git a/dotnet/src/Agents/AzureAI/AzureAIAgentThread.cs b/dotnet/src/Agents/AzureAI/AzureAIAgentThread.cs index cfde3d6da8ea..c5c0090ea170 100644 --- a/dotnet/src/Agents/AzureAI/AzureAIAgentThread.cs +++ b/dotnet/src/Agents/AzureAI/AzureAIAgentThread.cs @@ -15,7 +15,6 @@ namespace Microsoft.SemanticKernel.Agents.AzureAI; public class AzureAIAgentThread : AgentThread { private readonly AgentsClient _client; - private bool _isActive = false; private string? _id = null; /// @@ -40,50 +39,44 @@ public AzureAIAgentThread(AgentsClient client, string id) Verify.NotNull(id); this._client = client; - this._isActive = true; this._id = id; } - /// - public override bool IsActive => this._isActive; - /// public override string? Id => this._id; /// - public override async Task StartAsync(CancellationToken cancellationToken = default) + public override async Task CreateAsync(CancellationToken cancellationToken = default) { - if (this._isActive) + if (this._id is not null) { - throw new InvalidOperationException("You cannot start this thread, since the thread is already active."); + return this._id; } var assistantThreadResponse = await this._client.CreateThreadAsync(cancellationToken: cancellationToken).ConfigureAwait(false); this._id = assistantThreadResponse.Value.Id; - this._isActive = true; return assistantThreadResponse.Value.Id; } /// - public override async Task EndAsync(CancellationToken cancellationToken = default) + public override async Task DeleteAsync(CancellationToken cancellationToken = default) { - if (!this._isActive) + if (this._id is null) { - throw new InvalidOperationException("This thread cannot be ended, since the thread is not currently active."); + throw new InvalidOperationException("This thread cannot be ended, since it has not been started."); } await this._client.DeleteThreadAsync(this._id, cancellationToken).ConfigureAwait(false); - this._isActive = false; this._id = null; } /// public override async Task OnNewMessageAsync(ChatMessageContent newMessage, CancellationToken cancellationToken = default) { - if (!this._isActive) + if (this._id is null) { - throw new InvalidOperationException("Messages cannot be added to this thread, since the thread is not currently active."); + 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. @@ -96,9 +89,9 @@ public override async Task OnNewMessageAsync(ChatMessageContent newMessage, Canc /// public IAsyncEnumerable GetMessagesAsync(CancellationToken cancellationToken = default) { - if (!this._isActive) + if (this._id is null) { - throw new InvalidOperationException("The messages for this thread cannot be retrieved, since the thread is not currently active."); + 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/Core/ChatHistoryAgentThread.cs b/dotnet/src/Agents/Core/ChatHistoryAgentThread.cs index 6cea56034d17..cb13ead90e99 100644 --- a/dotnet/src/Agents/Core/ChatHistoryAgentThread.cs +++ b/dotnet/src/Agents/Core/ChatHistoryAgentThread.cs @@ -15,7 +15,6 @@ namespace Microsoft.SemanticKernel.Agents; public class ChatHistoryAgentThread : AgentThread { private readonly ChatHistory _chatHistory = new(); - private bool _isActive = false; private string? _id = null; /// @@ -34,41 +33,35 @@ public ChatHistoryAgentThread(ChatHistory chatHistory, string? id = null) { Verify.NotNull(chatHistory); this._chatHistory = chatHistory; - this._isActive = true; this._id = id ?? Guid.NewGuid().ToString("N"); } - /// - public override bool IsActive => this._isActive; - /// public override string? Id => this._id; /// - public override Task StartAsync(CancellationToken cancellationToken = default) + public override Task CreateAsync(CancellationToken cancellationToken = default) { - if (this._isActive) + if (this._id is not null) { - throw new InvalidOperationException("You cannot start this thread, since the thread is already active."); + return Task.FromResult(this._id); } this._id = Guid.NewGuid().ToString("N"); - this._isActive = true; return Task.FromResult(this._id); } /// - public override Task EndAsync(CancellationToken cancellationToken = default) + public override Task DeleteAsync(CancellationToken cancellationToken = default) { - if (!this._isActive) + if (this._id is null) { - throw new InvalidOperationException("This thread cannot be ended, since the thread is not currently active."); + throw new InvalidOperationException("This thread cannot be ended, since it has not been started."); } this._chatHistory.Clear(); this._id = null; - this._isActive = false; return Task.CompletedTask; } @@ -76,9 +69,9 @@ public override Task EndAsync(CancellationToken cancellationToken = default) /// public override Task OnNewMessageAsync(ChatMessageContent newMessage, CancellationToken cancellationToken = default) { - if (!this._isActive) + if (this._id is null) { - throw new InvalidOperationException("Messages cannot be added to this thread, since the thread is not currently active."); + throw new InvalidOperationException("Messages cannot be added to this thread, since the thread has not been started."); } this._chatHistory.Add(newMessage); @@ -88,9 +81,9 @@ public override Task OnNewMessageAsync(ChatMessageContent newMessage, Cancellati /// public IAsyncEnumerable GetMessagesAsync(CancellationToken cancellationToken = default) { - if (!this._isActive) + if (this._id is null) { - throw new InvalidOperationException("The chat history for this thread cannot be retrieved, since the thread is not currently active."); + 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/OpenAIAssistantAgentThread.cs b/dotnet/src/Agents/OpenAI/OpenAIAssistantAgentThread.cs index 67c46a616c12..f6be22a77c91 100644 --- a/dotnet/src/Agents/OpenAI/OpenAIAssistantAgentThread.cs +++ b/dotnet/src/Agents/OpenAI/OpenAIAssistantAgentThread.cs @@ -15,7 +15,6 @@ namespace Microsoft.SemanticKernel.Agents.OpenAI; public class OpenAIAssistantAgentThread : AgentThread { private readonly AssistantClient _client; - private bool _isActive = false; private string? _id = null; /// @@ -40,50 +39,44 @@ public OpenAIAssistantAgentThread(AssistantClient client, string id) Verify.NotNull(id); this._client = client; - this._isActive = true; this._id = id; } - /// - public override bool IsActive => this._isActive; - /// public override string? Id => this._id; /// - public override async Task StartAsync(CancellationToken cancellationToken = default) + public override async Task CreateAsync(CancellationToken cancellationToken = default) { - if (this._isActive) + if (this._id is not null) { - throw new InvalidOperationException("You cannot start this thread, since the thread is already active."); + return this._id; } var assistantThreadResponse = await this._client.CreateThreadAsync(cancellationToken: cancellationToken).ConfigureAwait(false); this._id = assistantThreadResponse.Value.Id; - this._isActive = true; return assistantThreadResponse.Value.Id; } /// - public override async Task EndAsync(CancellationToken cancellationToken = default) + public override async Task DeleteAsync(CancellationToken cancellationToken = default) { - if (!this._isActive) + if (this._id is null) { - throw new InvalidOperationException("This thread cannot be ended, since the thread is not currently active."); + throw new InvalidOperationException("This thread cannot be ended, since it has not been started."); } await this._client.DeleteThreadAsync(this._id, cancellationToken).ConfigureAwait(false); - this._isActive = false; this._id = null; } /// public override async Task OnNewMessageAsync(ChatMessageContent newMessage, CancellationToken cancellationToken = default) { - if (!this._isActive) + if (this._id is null) { - throw new InvalidOperationException("Messages cannot be added to this thread, since the thread is not currently active."); + 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. @@ -96,9 +89,9 @@ public override async Task OnNewMessageAsync(ChatMessageContent newMessage, Canc /// public IAsyncEnumerable GetMessagesAsync(CancellationToken cancellationToken = default) { - if (!this._isActive) + if (this._id is null) { - throw new InvalidOperationException("The messages for this thread cannot be retrieved, since the thread is not currently active."); + 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/IntegrationTests/Agents/CommonInterfaceConformance/AzureAIAgentFixture.cs b/dotnet/src/IntegrationTests/Agents/CommonInterfaceConformance/AzureAIAgentFixture.cs index 7680918f521e..e944d506af2a 100644 --- a/dotnet/src/IntegrationTests/Agents/CommonInterfaceConformance/AzureAIAgentFixture.cs +++ b/dotnet/src/IntegrationTests/Agents/CommonInterfaceConformance/AzureAIAgentFixture.cs @@ -47,7 +47,7 @@ public override Task DeleteThread(AgentThread thread) public override async Task DisposeAsync() { - if (this._thread!.IsActive) + if (this._thread!.Id is not null) { await this._agentsClient!.DeleteThreadAsync(this._thread!.Id); } diff --git a/dotnet/src/IntegrationTests/Agents/CommonInterfaceConformance/OpenAIAssistantAgentFixture.cs b/dotnet/src/IntegrationTests/Agents/CommonInterfaceConformance/OpenAIAssistantAgentFixture.cs index ba143e72cb4b..c62d913a4b0f 100644 --- a/dotnet/src/IntegrationTests/Agents/CommonInterfaceConformance/OpenAIAssistantAgentFixture.cs +++ b/dotnet/src/IntegrationTests/Agents/CommonInterfaceConformance/OpenAIAssistantAgentFixture.cs @@ -46,7 +46,7 @@ public override async Task GetChatHistory() public override async Task DisposeAsync() { - if (this._thread!.IsActive) + if (this._thread!.Id is not null) { await this._assistantClient!.DeleteThreadAsync(this._thread!.Id); } From 0c2217ee7176f1ed28121ba1a6df83af95649eee Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Fri, 21 Mar 2025 19:20:47 +0000 Subject: [PATCH 10/10] Fix spelling error. --- dotnet/src/Agents/Abstractions/AggregatorAgent.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dotnet/src/Agents/Abstractions/AggregatorAgent.cs b/dotnet/src/Agents/Abstractions/AggregatorAgent.cs index a081d54a1c36..e71b7ff03f1a 100644 --- a/dotnet/src/Agents/Abstractions/AggregatorAgent.cs +++ b/dotnet/src/Agents/Abstractions/AggregatorAgent.cs @@ -53,7 +53,7 @@ public override IAsyncEnumerable> InvokeAs AgentInvokeOptions? options = null, CancellationToken cancellationToken = default) { - // TODO: Need to determine the corrrect approach here. + // TODO: Need to determine the correct approach here. throw new NotImplementedException(); } @@ -66,7 +66,7 @@ public override IAsyncEnumerable> AgentInvokeOptions? options = null, CancellationToken cancellationToken = default) { - // TODO: Need to determine the corrrect approach here. + // TODO: Need to determine the correct approach here. throw new NotImplementedException(); }