diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index eb2578453b..6bb7c4680c 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -6,6 +6,7 @@ + diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 9dc111f7d5..a79f2a4306 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -8,6 +8,9 @@ + + + @@ -52,6 +55,7 @@ + @@ -100,6 +104,7 @@ + diff --git a/dotnet/samples/GettingStarted/GettingStarted.csproj b/dotnet/samples/GettingStarted/GettingStarted.csproj index ad71ab05d8..d3ccec9454 100644 --- a/dotnet/samples/GettingStarted/GettingStarted.csproj +++ b/dotnet/samples/GettingStarted/GettingStarted.csproj @@ -27,6 +27,7 @@ + diff --git a/dotnet/samples/GettingStarted/Providers/ChatClientAgent_With_AzureAIAgentsPersistent.cs b/dotnet/samples/GettingStarted/Providers/ChatClientAgent_With_AzureAIAgentsPersistent.cs new file mode 100644 index 0000000000..556cca5060 --- /dev/null +++ b/dotnet/samples/GettingStarted/Providers/ChatClientAgent_With_AzureAIAgentsPersistent.cs @@ -0,0 +1,64 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Azure.AI.Agents.Persistent; +using Azure.Identity; +using Microsoft.Agents; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.AI.AzureAIAgentsPersistent; +using Microsoft.Shared.Samples; + +namespace Providers; + +/// +/// Shows how to use with Azure AI Persistent Agents. +/// +/// +/// Running "az login" command in terminal is required for authentication with Azure AI service. +/// +public sealed class ChatClientAgent_With_AzureAIAgentsPersistent(ITestOutputHelper output) : AgentSample(output) +{ + private const string JokerName = "Joker"; + private const string JokerInstructions = "You are good at telling jokes."; + + [Fact] + public async Task RunWithAzureAIAgentsPersistent() + { + // Get a client to create server side agents with. + var persistentAgentsClient = new PersistentAgentsClient(TestConfiguration.AzureAI.Endpoint, new AzureCliCredential()); + + // Create a server side agent to work with. + var persistentAgentResponse = await persistentAgentsClient.Administration.CreateAgentAsync( + model: TestConfiguration.AzureAI.DeploymentName, + name: JokerName, + instructions: JokerInstructions); + + var persistentAgent = persistentAgentResponse.Value; + + // Get the chat client to use for the agent. + using var chatClient = persistentAgentsClient.AsIChatClient(persistentAgent.Id); + + // Define the agent + ChatClientAgent agent = new(chatClient); + + // Start a new thread for the agent conversation. + AgentThread thread = agent.GetNewThread(); + + // Respond to user input + await RunAgentAsync("Tell me a joke about a pirate."); + await RunAgentAsync("Now add some emojis to the joke."); + + // Local function to run agent and display the conversation messages for the thread. + async Task RunAgentAsync(string input) + { + this.WriteUserMessage(input); + + var response = await agent.RunAsync(input, thread); + + this.WriteResponseOutput(response); + } + + // Cleanup + await persistentAgentsClient.Threads.DeleteThreadAsync(thread.Id); + await persistentAgentsClient.Administration.DeleteAgentAsync(persistentAgent.Id); + } +} diff --git a/dotnet/src/Microsoft.Extensions.AI.AzureAIAgentsPersistent/Microsoft.Extensions.AI.AzureAIAgentsPersistent.csproj b/dotnet/src/Microsoft.Extensions.AI.AzureAIAgentsPersistent/Microsoft.Extensions.AI.AzureAIAgentsPersistent.csproj new file mode 100644 index 0000000000..f9c2e8fcb3 --- /dev/null +++ b/dotnet/src/Microsoft.Extensions.AI.AzureAIAgentsPersistent/Microsoft.Extensions.AI.AzureAIAgentsPersistent.csproj @@ -0,0 +1,22 @@ + + + + $(ProjectsTargetFrameworks) + $(ProjectsDebugTargetFrameworks) + alpha + + + + + + + + + + + + Microsoft.Extensions.AI.AzureAIAgentsPersistent + Implementation of generative AI abstractions for Azure AI Persistent Agents. + + + diff --git a/dotnet/src/Microsoft.Extensions.AI.AzureAIAgentsPersistent/PersistentAgentsChatClient.cs b/dotnet/src/Microsoft.Extensions.AI.AzureAIAgentsPersistent/PersistentAgentsChatClient.cs new file mode 100644 index 0000000000..dea65abd9e --- /dev/null +++ b/dotnet/src/Microsoft.Extensions.AI.AzureAIAgentsPersistent/PersistentAgentsChatClient.cs @@ -0,0 +1,432 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using Azure.AI.Agents.Persistent; + +namespace Microsoft.Extensions.AI.AzureAIAgentsPersistent; + +/// Represents an for an Azure.AI.Agents.Persistent . +public sealed partial class PersistentAgentsChatClient : IChatClient +{ + /// The name of the chat client provider. + private const string ProviderName = "azure"; + + /// The underlying . + private readonly PersistentAgentsClient _client; + + /// Metadata for the client. + private readonly ChatClientMetadata _metadata; + + /// The ID of the agent to use. + private readonly string _agentId; + + /// The thread ID to use if none is supplied in . + private readonly string? _threadId; + + /// Initializes a new instance of the class for the specified . + public PersistentAgentsChatClient(PersistentAgentsClient client, string agentId, string? threadId) + { + if (client is null) + { + throw new ArgumentNullException(nameof(client)); + } + + if (string.IsNullOrWhiteSpace(agentId)) + { + throw new ArgumentException("Cannot be null or be comprised of only whitespace.", nameof(agentId)); + } + + this._client = client; + this._agentId = agentId; + this._threadId = threadId; + + this._metadata = new(ProviderName); + } + + /// + public object? GetService(Type serviceType, object? serviceKey = null) => + serviceType is null ? throw new ArgumentNullException(nameof(serviceType)) : + serviceKey is not null ? null : + serviceType == typeof(ChatClientMetadata) ? this._metadata : + serviceType == typeof(PersistentAgentsClient) ? this._client : + serviceType.IsInstanceOfType(this) ? this : + null; + + /// + public Task GetResponseAsync( + IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) => + this.GetStreamingResponseAsync(messages, options, cancellationToken).ToChatResponseAsync(cancellationToken); + + /// + public async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + if (messages is null) + { + throw new ArgumentNullException(nameof(messages)); + } + + // Extract necessary state from messages and options. + (ThreadAndRunOptions runOptions, List? toolResults) = this.CreateRunOptions(messages, options); + + // Get the thread ID. + string? threadId = options?.ConversationId ?? this._threadId; + if (threadId is null && toolResults is not null) + { + throw new ArgumentException("No thread ID was provided, but chat messages includes tool results.", nameof(messages)); + } + + // Get any active run ID for this thread. + ThreadRun? threadRun = null; + if (threadId is not null) + { + await foreach (var run in this._client.Runs.GetRunsAsync(threadId, limit: 1, ListSortOrder.Descending, cancellationToken: cancellationToken).ConfigureAwait(false)) + { + if (run.Status != RunStatus.Completed && run.Status != RunStatus.Cancelled && run.Status != RunStatus.Failed && run.Status != RunStatus.Expired) + { + threadRun = run; + break; + } + } + } + + // Submit the request. + IAsyncEnumerable updates; + if (threadRun is not null && + ConvertFunctionResultsToToolOutput(toolResults, out List? toolOutputs) is { } toolRunId && + toolRunId == threadRun.Id) + { + // There's an active run and we have tool results to submit, so submit the results and continue streaming. + // This is going to ignore any additional messages in the run options, as we are only submitting tool outputs, + // but there doesn't appear to be a way to submit additional messages, and having such additional messages is rare. + updates = this._client.Runs.SubmitToolOutputsToStreamAsync(threadRun, toolOutputs, cancellationToken); + } + else + { + if (threadId is null) + { + // No thread ID was provided, so create a new thread. + PersistentAgentThread thread = await this._client.Threads.CreateThreadAsync(runOptions.ThreadOptions.Messages, runOptions.ToolResources, runOptions.Metadata, cancellationToken).ConfigureAwait(false); + runOptions.ThreadOptions.Messages.Clear(); + threadId = thread.Id; + } + else if (threadRun is not null) + { + // There was an active run; we need to cancel it before starting a new run. + await this._client.Runs.CancelRunAsync(threadId, threadRun.Id, cancellationToken).ConfigureAwait(false); + threadRun = null; + } + + // Now create a new run and stream the results. + updates = this._client.Runs.CreateRunStreamingAsync( + threadId: threadId, + agentId: this._agentId, + overrideModelName: runOptions?.OverrideModelName, + overrideInstructions: runOptions?.OverrideInstructions, + additionalInstructions: null, + additionalMessages: runOptions?.ThreadOptions.Messages, + overrideTools: runOptions?.OverrideTools, + temperature: runOptions?.Temperature, + topP: runOptions?.TopP, + maxPromptTokens: runOptions?.MaxPromptTokens, + maxCompletionTokens: runOptions?.MaxCompletionTokens, + truncationStrategy: runOptions?.TruncationStrategy, + toolChoice: runOptions?.ToolChoice, + responseFormat: runOptions?.ResponseFormat, + parallelToolCalls: runOptions?.ParallelToolCalls, + metadata: runOptions?.Metadata, + cancellationToken); + } + + // Process each update. + string? responseId = null; + await foreach (var update in updates.ConfigureAwait(false)) + { + switch (update) + { + case ThreadUpdate tu: + threadId ??= tu.Value.Id; + goto default; + + case RunUpdate ru: + threadId ??= ru.Value.ThreadId; + responseId ??= ru.Value.Id; + + ChatResponseUpdate ruUpdate = new() + { + AuthorName = ru.Value.AssistantId, + ConversationId = threadId, + CreatedAt = ru.Value.CreatedAt, + MessageId = responseId, + ModelId = ru.Value.Model, + RawRepresentation = ru, + ResponseId = responseId, + Role = ChatRole.Assistant, + }; + + if (ru.Value.Usage is { } usage) + { + ruUpdate.Contents.Add(new UsageContent(new() + { + InputTokenCount = usage.PromptTokens, + OutputTokenCount = usage.CompletionTokens, + TotalTokenCount = usage.TotalTokens, + })); + } + + if (ru is RequiredActionUpdate rau && rau.ToolCallId is string toolCallId && rau.FunctionName is string functionName) + { + ruUpdate.Contents.Add( + new FunctionCallContent( + JsonSerializer.Serialize([ru.Value.Id, toolCallId], AgentsChatClientJsonContext.Default.StringArray), + functionName, + JsonSerializer.Deserialize(rau.FunctionArguments, AgentsChatClientJsonContext.Default.IDictionaryStringObject)!)); + } + + yield return ruUpdate; + break; + + case MessageContentUpdate mcu: + yield return new(mcu.Role == MessageRole.User ? ChatRole.User : ChatRole.Assistant, mcu.Text) + { + ConversationId = threadId, + MessageId = responseId, + RawRepresentation = mcu, + ResponseId = responseId, + }; + break; + + default: + yield return new ChatResponseUpdate + { + ConversationId = threadId, + MessageId = responseId, + RawRepresentation = update, + ResponseId = responseId, + Role = ChatRole.Assistant, + }; + break; + } + } + } + + /// + public void Dispose() { } + + /// + /// Creates the to use for the request and extracts any function result contents + /// that need to be submitted as tool results. + /// + private (ThreadAndRunOptions RunOptions, List? ToolResults) CreateRunOptions( + IEnumerable messages, ChatOptions? options) + { + // Create the options instance to populate, either a fresh or using one the caller provides. + ThreadAndRunOptions runOptions = + options?.RawRepresentationFactory?.Invoke(this) as ThreadAndRunOptions ?? + new(); + + // Populate the run options from the ChatOptions, if provided. + if (options is not null) + { + runOptions.MaxCompletionTokens ??= options.MaxOutputTokens; + runOptions.OverrideModelName ??= options.ModelId; + runOptions.TopP ??= options.TopP; + runOptions.Temperature ??= options.Temperature; + runOptions.ParallelToolCalls ??= options.AllowMultipleToolCalls; + // Ignored: options.TopK, options.FrequencyPenalty, options.Seed, options.StopSequences + + // TODO: When moved to Azure.AI.Agents.Persistent, merge agent tools with override tools, in similar way like here: + // https://github.com/dotnet/extensions/blob/694b95ef75c6bd9de00ef761dadae4e70ee8739f/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIAssistantChatClient.cs#L263-L279 + if (options.Tools is { Count: > 0 } tools) + { + // The caller can provide tools in the supplied ThreadAndRunOptions. Augment it with any supplied via ChatOptions.Tools. + IList toolDefinitions = runOptions.OverrideTools is not null ? [.. runOptions.OverrideTools] : []; + foreach (AITool tool in tools) + { + switch (tool) + { + case AIFunction aiFunction: + toolDefinitions.Add(new FunctionToolDefinition( + aiFunction.Name, + aiFunction.Description, + BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(aiFunction.JsonSchema, AgentsChatClientJsonContext.Default.JsonElement)))); + break; + + case HostedCodeInterpreterTool: + toolDefinitions.Add(new CodeInterpreterToolDefinition()); + break; + + case HostedWebSearchTool webSearch when webSearch.AdditionalProperties?.TryGetValue("connectionId", out object? connectionId) is true: + toolDefinitions.Add(new BingGroundingToolDefinition(new BingGroundingSearchToolParameters([new BingGroundingSearchConfiguration(connectionId!.ToString())]))); + break; + } + } + + if (toolDefinitions.Count > 0) + { + runOptions.OverrideTools = toolDefinitions; + } + } + + // Store the tool mode, if relevant. + if (runOptions.ToolChoice is null) + { + switch (options.ToolMode) + { + case NoneChatToolMode: + runOptions.ToolChoice = BinaryData.FromString("none"); + break; + + case RequiredChatToolMode required: + runOptions.ToolChoice = required.RequiredFunctionName is string functionName ? + BinaryData.FromString($$"""{"type": "function", "function": {"name": "{{functionName}}"} }""") : + BinaryData.FromString("required"); + break; + } + } + + // Store the response format, if relevant. + if (runOptions.ResponseFormat is null) + { + if (options.ResponseFormat is ChatResponseFormatJson jsonFormat) + { + runOptions.ResponseFormat = jsonFormat.Schema is { } schema ? + BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(new() + { + ["type"] = "json_schema", + ["json_schema"] = JsonSerializer.SerializeToNode(schema, AgentsChatClientJsonContext.Default.JsonNode), + }, AgentsChatClientJsonContext.Default.JsonObject)) : + BinaryData.FromString("""{ "type": "json_object" }"""); + } + } + } + + // Process ChatMessages. System messages are turned into additional instructions. + // All other messages are added 1:1, treating assistant messages as agent messages + // and everything else as user messages. + StringBuilder? instructions = null; + List? functionResults = null; + + runOptions.ThreadOptions ??= new(); + + foreach (var chatMessage in messages) + { + List messageContents = []; + + if (chatMessage.Role == ChatRole.System || + chatMessage.Role == new ChatRole("developer")) + { + instructions ??= new(); + foreach (var textContent in chatMessage.Contents.OfType()) + { + _ = instructions.Append(textContent); + } + + continue; + } + + foreach (AIContent content in chatMessage.Contents) + { + switch (content) + { + case TextContent text: + messageContents.Add(new MessageInputTextBlock(text.Text)); + break; + + case DataContent image when image.HasTopLevelMediaType("image"): + messageContents.Add(new MessageInputImageUriBlock(new MessageImageUriParam(image.Uri))); + break; + + case UriContent image when image.HasTopLevelMediaType("image"): + messageContents.Add(new MessageInputImageUriBlock(new MessageImageUriParam(image.Uri.ToString()))); + break; + + case FunctionResultContent result: + (functionResults ??= []).Add(result); + break; + + default: + if (content.RawRepresentation is MessageInputContentBlock rawContent) + { + messageContents.Add(rawContent); + } + break; + } + } + + if (messageContents.Count > 0) + { + runOptions.ThreadOptions.Messages.Add(new ThreadMessageOptions( + chatMessage.Role == ChatRole.Assistant ? MessageRole.Agent : MessageRole.User, + messageContents)); + } + } + + if (instructions is not null) + { + runOptions.OverrideInstructions = instructions.ToString(); + } + + return (runOptions, functionResults); + } + + /// Convert instances to instances."/> + /// The tool results to process. + /// The generated list of tool outputs, if any could be created. + /// The run ID associated with the corresponding function call requests. + private static string? ConvertFunctionResultsToToolOutput(List? toolResults, out List? toolOutputs) + { + string? runId = null; + toolOutputs = null; + if (toolResults?.Count > 0) + { + foreach (var frc in toolResults) + { + // When creating the FunctionCallContext, we created it with a CallId == [runId, callId]. + // We need to extract the run ID and ensure that the ToolOutput we send back to Azure + // is only the call ID. + string[]? runAndCallIDs; + try + { + runAndCallIDs = JsonSerializer.Deserialize(frc.CallId, AgentsChatClientJsonContext.Default.StringArray); + } +#pragma warning disable CA1031 // Do not catch general exception types + catch +#pragma warning restore CA1031 // Do not catch general exception types + { + continue; + } + + if (runAndCallIDs is null || + runAndCallIDs.Length != 2 || + string.IsNullOrWhiteSpace(runAndCallIDs[0]) || // run ID + string.IsNullOrWhiteSpace(runAndCallIDs[1]) || // call ID + (runId is not null && runId != runAndCallIDs[0])) + { + continue; + } + + runId = runAndCallIDs[0]; + (toolOutputs ??= []).Add(new(runAndCallIDs[1], frc.Result?.ToString() ?? string.Empty)); + } + } + + return runId; + } + + [JsonSerializable(typeof(JsonElement))] + [JsonSerializable(typeof(JsonNode))] + [JsonSerializable(typeof(JsonObject))] + [JsonSerializable(typeof(string[]))] + [JsonSerializable(typeof(IDictionary))] + private sealed partial class AgentsChatClientJsonContext : JsonSerializerContext; +} diff --git a/dotnet/src/Microsoft.Extensions.AI.AzureAIAgentsPersistent/PersistentAgentsClientExtensions.cs b/dotnet/src/Microsoft.Extensions.AI.AzureAIAgentsPersistent/PersistentAgentsClientExtensions.cs new file mode 100644 index 0000000000..a70d3e91b9 --- /dev/null +++ b/dotnet/src/Microsoft.Extensions.AI.AzureAIAgentsPersistent/PersistentAgentsClientExtensions.cs @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Azure.AI.Agents.Persistent; + +namespace Microsoft.Extensions.AI.AzureAIAgentsPersistent; + +/// +/// Provides extension methods for . +/// +public static class PersistentAgentsClientExtensions +{ + /// + /// Creates an for a client for interacting with a specific agent. + /// + /// The instance to be accessed as an . + /// The unique identifier of the agent with which to interact. + /// + /// An optional existing thread identifier for the chat session. This serves as a default, and may be overridden per call to + /// or via the + /// property. If not thread ID is provided via either mechanism, a new thread will be created for the request. + /// + /// An instance configured to interact with the specified agent and thread. + public static IChatClient AsIChatClient(this PersistentAgentsClient client, string agentId, string? threadId = null) => + new PersistentAgentsChatClient(client, agentId, threadId); +} diff --git a/dotnet/src/Shared/IntegrationTests/AzureAIConfiguration.cs b/dotnet/src/Shared/IntegrationTests/AzureAIConfiguration.cs new file mode 100644 index 0000000000..29d76ac232 --- /dev/null +++ b/dotnet/src/Shared/IntegrationTests/AzureAIConfiguration.cs @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Shared.IntegrationTests; + +#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. +#pragma warning disable CA1812 // Internal class that is apparently never instantiated. + +internal sealed class AzureAIConfiguration +{ + public string Endpoint { get; set; } + + public string DeploymentName { get; set; } +} diff --git a/dotnet/src/Shared/Samples/TestConfiguration.cs b/dotnet/src/Shared/Samples/TestConfiguration.cs index 58c831d079..89a1404d2d 100644 --- a/dotnet/src/Shared/Samples/TestConfiguration.cs +++ b/dotnet/src/Shared/Samples/TestConfiguration.cs @@ -43,6 +43,16 @@ public sealed class TestConfiguration public string? ApiKey { get; set; } } + /// Represents the configuration settings required to interact with the Azure AI service. + public sealed class AzureAIConfig + { + /// Gets or sets the endpoint of Azure AI Foundry project. + public string? Endpoint { get; set; } + + /// Gets or sets the name of the model deployment. + public string? DeploymentName { get; set; } + } + /// /// Initializes the configuration system with the specified configuration root. /// @@ -53,6 +63,7 @@ public sealed class TestConfiguration } #region Private Members + private readonly IConfigurationRoot _configRoot; private static TestConfiguration? s_instance; @@ -66,6 +77,11 @@ public sealed class TestConfiguration /// private static IConfigurationRoot? ConfigurationRoot => s_instance?._configRoot; + /// + /// Gets the configuration settings for the AzureAI integration. + /// + public static AzureAIConfig AzureAI => LoadSection(); + /// /// Retrieves a configuration section based on the specified key. /// diff --git a/dotnet/tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsPersistent.IntegrationTests.csproj b/dotnet/tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsPersistent.IntegrationTests.csproj new file mode 100644 index 0000000000..4d383aa05d --- /dev/null +++ b/dotnet/tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsPersistent.IntegrationTests.csproj @@ -0,0 +1,14 @@ + + + + $(ProjectsTargetFrameworks) + $(ProjectsDebugTargetFrameworks) + True + + + + + + + + diff --git a/dotnet/tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsPersistentFixture.cs b/dotnet/tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsPersistentFixture.cs new file mode 100644 index 0000000000..2e170025e1 --- /dev/null +++ b/dotnet/tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsPersistentFixture.cs @@ -0,0 +1,97 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using AgentConformance.IntegrationTests.Support; +using AgentConformanceTests; +using Azure; +using Azure.AI.Agents.Persistent; +using Azure.Identity; +using Microsoft.Agents; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.AI.AzureAIAgentsPersistent; +using Shared.IntegrationTests; + +namespace AzureAIAgentsPersistent.IntegrationTests; + +public class AzureAIAgentsPersistentFixture : AgentFixture +{ +#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 Agent _agent; + private PersistentAgentsClient _persistentAgentsClient; + private PersistentAgent _persistentAgent; +#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. + + public override Agent Agent => this._agent; + + public override async Task> GetChatHistoryAsync(AgentThread thread) + { + if (thread is not ChatClientAgentThread chatClientThread) + { + throw new InvalidOperationException($"The thread must be of type {nameof(ChatClientAgentThread)} to retrieve chat history."); + } + + List messages = []; + + AsyncPageable threadMessages = this._persistentAgentsClient.Messages.GetMessagesAsync(threadId: thread.Id, order: ListSortOrder.Ascending); + + await foreach (var threadMessage in threadMessages) + { + var message = new ChatMessage + { + Role = threadMessage.Role == MessageRole.User ? ChatRole.User : ChatRole.Assistant + }; + + foreach (var content in threadMessage.ContentItems) + { + if (content is MessageTextContent textContent) + { + message.Contents.Add(new TextContent(textContent.Text)); + } + } + + messages.Add(message); + } + + return messages; + } + + public override Task DeleteThreadAsync(AgentThread thread) + { + if (thread?.Id is not null) + { + return this._persistentAgentsClient.Threads.DeleteThreadAsync(thread.Id); + } + + return Task.CompletedTask; + } + + public override Task DisposeAsync() + { + if (this._persistentAgentsClient is not null && this._persistentAgent is not null) + { + return this._persistentAgentsClient.Administration.DeleteAgentAsync(this._persistentAgent.Id); + } + + return Task.CompletedTask; + } + + public override async Task InitializeAsync() + { + var config = TestConfiguration.LoadSection(); + + this._persistentAgentsClient = new(config.Endpoint, new AzureCliCredential()); + + var persistentAgentResponse = await this._persistentAgentsClient.Administration.CreateAgentAsync( + model: config.DeploymentName, + name: "HelpfulAssistant", + instructions: "You are a helpful assistant."); + + this._persistentAgent = persistentAgentResponse.Value; + + var chatClient = this._persistentAgentsClient.AsIChatClient(this._persistentAgent.Id); + + this._agent = new ChatClientAgent(chatClient); + } +} diff --git a/dotnet/tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsPersistentInvokeStreamingTests.cs b/dotnet/tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsPersistentInvokeStreamingTests.cs new file mode 100644 index 0000000000..c003bec1f0 --- /dev/null +++ b/dotnet/tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsPersistentInvokeStreamingTests.cs @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft. All rights reserved. + +using AgentConformance.IntegrationTests; + +namespace AzureAIAgentsPersistent.IntegrationTests; + +public class AzureAIAgentsPersistentInvokeStreamingTests() : RunStreamingAsyncTests(() => new()) +{ +} diff --git a/dotnet/tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsPersistentInvokeTests.cs b/dotnet/tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsPersistentInvokeTests.cs new file mode 100644 index 0000000000..877076a842 --- /dev/null +++ b/dotnet/tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsPersistentInvokeTests.cs @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft. All rights reserved. + +using AgentConformance.IntegrationTests; + +namespace AzureAIAgentsPersistent.IntegrationTests; + +public class AzureAIAgentsPersistentInvokeTests() : RunAsyncTests(() => new()) +{ +}