From 54c3eb726a519626a2b08216cb10cdd73433cd6b Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Thu, 16 Oct 2025 16:39:48 +0100 Subject: [PATCH] .NET: Add support for getting and creating Assistant and Foundry agents with ChatClientAgentOptions (#1304) * Add support for getting and creating Assistant and Foundry agents with ChatClientAgentOptions * Fix options cloning and agent creation * Fix inconsistency * Add support for mapping more tools and integration tests for ensuring CreateAIAgent works with those tools. * Add support for additional openai tools with tests. * Remove special casing for function tools, since it's either not supported yet, or requires a lot of code duplication. * Removed unused using. * Fix broken unit tests * Change integration test to reduce flakiness. --- dotnet/.vscode/settings.json | 3 +- .../PersistentAgentsClientExtensions.cs | 330 +++++++++++++ .../OpenAIAssistantClientExtensions.cs | 308 ++++++++++--- .../ChatClient/ChatClientAgentOptions.cs | 2 +- ...AIAgentsPersistent.IntegrationTests.csproj | 1 + .../AzureAIAgentsPersistentCreateTests.cs | 262 +++++++++++ .../PersistentAgentsClientExtensionsTests.cs | 432 ++++++++++++++++++ .../OpenAIAssistantClientExtensionsTests.cs | 259 +++++++++++ .../OpenAIAssistant.IntegrationTests.csproj | 4 +- .../OpenAIAssistantClientExtensionsTests.cs | 192 ++++++++ 10 files changed, 1730 insertions(+), 63 deletions(-) create mode 100644 dotnet/tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsPersistentCreateTests.cs create mode 100644 dotnet/tests/OpenAIAssistant.IntegrationTests/OpenAIAssistantClientExtensionsTests.cs diff --git a/dotnet/.vscode/settings.json b/dotnet/.vscode/settings.json index 2a6a38bc59..4fa848ae28 100644 --- a/dotnet/.vscode/settings.json +++ b/dotnet/.vscode/settings.json @@ -1,4 +1,5 @@ { "dotnet.defaultSolution": "agent-framework-dotnet.slnx", - "git.openRepositoryInParentFolders": "always" + "git.openRepositoryInParentFolders": "always", + "chat.agent.enabled": true } diff --git a/dotnet/src/Microsoft.Agents.AI.AzureAI/PersistentAgentsClientExtensions.cs b/dotnet/src/Microsoft.Agents.AI.AzureAI/PersistentAgentsClientExtensions.cs index 72c77a94a4..1d5f228fcc 100644 --- a/dotnet/src/Microsoft.Agents.AI.AzureAI/PersistentAgentsClientExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.AzureAI/PersistentAgentsClientExtensions.cs @@ -127,6 +127,147 @@ public static class PersistentAgentsClientExtensions return persistentAgentsClient.GetAIAgent(persistentAgentResponse, chatOptions, clientFactory); } + /// + /// Gets a runnable agent instance from the provided response containing persistent agent metadata. + /// + /// The client used to interact with persistent agents. Cannot be . + /// The response containing the persistent agent to be converted. Cannot be . + /// Full set of options to configure the agent. + /// Provides a way to customize the creation of the underlying used by the agent. + /// A instance that can be used to perform operations on the persistent agent. + /// Thrown when or is . + public static ChatClientAgent GetAIAgent(this PersistentAgentsClient persistentAgentsClient, Response persistentAgentResponse, ChatClientAgentOptions options, Func? clientFactory = null) + { + if (persistentAgentResponse is null) + { + throw new ArgumentNullException(nameof(persistentAgentResponse)); + } + + return GetAIAgent(persistentAgentsClient, persistentAgentResponse.Value, options, clientFactory); + } + + /// + /// Gets a runnable agent instance from a containing metadata about a persistent agent. + /// + /// The client used to interact with persistent agents. Cannot be . + /// The persistent agent metadata to be converted. Cannot be . + /// Full set of options to configure the agent. + /// Provides a way to customize the creation of the underlying used by the agent. + /// A instance that can be used to perform operations on the persistent agent. + /// Thrown when or is . + public static ChatClientAgent GetAIAgent(this PersistentAgentsClient persistentAgentsClient, PersistentAgent persistentAgentMetadata, ChatClientAgentOptions options, Func? clientFactory = null) + { + if (persistentAgentMetadata is null) + { + throw new ArgumentNullException(nameof(persistentAgentMetadata)); + } + + if (persistentAgentsClient is null) + { + throw new ArgumentNullException(nameof(persistentAgentsClient)); + } + + if (options is null) + { + throw new ArgumentNullException(nameof(options)); + } + + var chatClient = persistentAgentsClient.AsIChatClient(persistentAgentMetadata.Id); + + if (clientFactory is not null) + { + chatClient = clientFactory(chatClient); + } + + var agentOptions = new ChatClientAgentOptions() + { + Id = persistentAgentMetadata.Id, + Name = options.Name ?? persistentAgentMetadata.Name, + Description = options.Description ?? persistentAgentMetadata.Description, + Instructions = options.Instructions ?? persistentAgentMetadata.Instructions, + ChatOptions = options.ChatOptions, + AIContextProviderFactory = options.AIContextProviderFactory, + ChatMessageStoreFactory = options.ChatMessageStoreFactory, + UseProvidedChatClientAsIs = options.UseProvidedChatClientAsIs + }; + + return new ChatClientAgent(chatClient, agentOptions); + } + + /// + /// Retrieves an existing server side agent, wrapped as a using the provided . + /// + /// The to create the with. + /// The ID of the server side agent to create a for. + /// Full set of options to configure the agent. + /// Provides a way to customize the creation of the underlying used by the agent. + /// The to monitor for cancellation requests. The default is . + /// A instance that can be used to perform operations on the persistent agent. + /// Thrown when or is . + /// Thrown when is empty or whitespace. + public static ChatClientAgent GetAIAgent( + this PersistentAgentsClient persistentAgentsClient, + string agentId, + ChatClientAgentOptions options, + Func? clientFactory = null, + CancellationToken cancellationToken = default) + { + if (persistentAgentsClient is null) + { + throw new ArgumentNullException(nameof(persistentAgentsClient)); + } + + if (string.IsNullOrWhiteSpace(agentId)) + { + throw new ArgumentException($"{nameof(agentId)} should not be null or whitespace.", nameof(agentId)); + } + + if (options is null) + { + throw new ArgumentNullException(nameof(options)); + } + + var persistentAgentResponse = persistentAgentsClient.Administration.GetAgent(agentId, cancellationToken); + return persistentAgentsClient.GetAIAgent(persistentAgentResponse, options, clientFactory); + } + + /// + /// Retrieves an existing server side agent, wrapped as a using the provided . + /// + /// The to create the with. + /// The ID of the server side agent to create a for. + /// Full set of options to configure the agent. + /// Provides a way to customize the creation of the underlying used by the agent. + /// The to monitor for cancellation requests. The default is . + /// A instance that can be used to perform operations on the persistent agent. + /// Thrown when or is . + /// Thrown when is empty or whitespace. + public static async Task GetAIAgentAsync( + this PersistentAgentsClient persistentAgentsClient, + string agentId, + ChatClientAgentOptions options, + Func? clientFactory = null, + CancellationToken cancellationToken = default) + { + if (persistentAgentsClient is null) + { + throw new ArgumentNullException(nameof(persistentAgentsClient)); + } + + if (string.IsNullOrWhiteSpace(agentId)) + { + throw new ArgumentException($"{nameof(agentId)} should not be null or whitespace.", nameof(agentId)); + } + + if (options is null) + { + throw new ArgumentNullException(nameof(options)); + } + + var persistentAgentResponse = await persistentAgentsClient.Administration.GetAgentAsync(agentId, cancellationToken).ConfigureAwait(false); + return persistentAgentsClient.GetAIAgent(persistentAgentResponse, options, clientFactory); + } + /// /// Creates a new server side agent using the provided . /// @@ -234,4 +375,193 @@ public static class PersistentAgentsClientExtensions // Get a local proxy for the agent to work with. return persistentAgentsClient.GetAIAgent(createPersistentAgentResponse.Value.Id, clientFactory: clientFactory, cancellationToken: cancellationToken); } + + /// + /// Creates a new server side agent using the provided . + /// + /// The to create the agent with. + /// The model to be used by the agent. + /// Full set of options to configure the agent. + /// Provides a way to customize the creation of the underlying used by the agent. + /// The to monitor for cancellation requests. The default is . + /// A instance that can be used to perform operations on the newly created agent. + /// Thrown when or or is . + /// Thrown when is empty or whitespace. + public static ChatClientAgent CreateAIAgent( + this PersistentAgentsClient persistentAgentsClient, + string model, + ChatClientAgentOptions options, + Func? clientFactory = null, + CancellationToken cancellationToken = default) + { + if (persistentAgentsClient is null) + { + throw new ArgumentNullException(nameof(persistentAgentsClient)); + } + + if (string.IsNullOrWhiteSpace(model)) + { + throw new ArgumentException($"{nameof(model)} should not be null or whitespace.", nameof(model)); + } + + if (options is null) + { + throw new ArgumentNullException(nameof(options)); + } + + var toolDefinitionsAndResources = ConvertAIToolsToToolDefinitions(options.ChatOptions?.Tools); + + var createPersistentAgentResponse = persistentAgentsClient.Administration.CreateAgent( + model: model, + name: options.Name, + description: options.Description, + instructions: options.Instructions, + tools: toolDefinitionsAndResources.ToolDefinitions, + toolResources: toolDefinitionsAndResources.ToolResources, + temperature: null, + topP: null, + responseFormat: null, + metadata: null, + cancellationToken: cancellationToken); + + if (options.ChatOptions?.Tools is { Count: > 0 } && (toolDefinitionsAndResources.FunctionToolsAndOtherTools is null || options.ChatOptions.Tools.Count != toolDefinitionsAndResources.FunctionToolsAndOtherTools.Count)) + { + options = options.Clone(); + options.ChatOptions!.Tools = toolDefinitionsAndResources.FunctionToolsAndOtherTools; + } + + // Get a local proxy for the agent to work with. + return persistentAgentsClient.GetAIAgent(createPersistentAgentResponse.Value.Id, options, clientFactory: clientFactory, cancellationToken: cancellationToken); + } + + /// + /// Creates a new server side agent using the provided . + /// + /// The to create the agent with. + /// The model to be used by the agent. + /// Full set of options to configure the agent. + /// Provides a way to customize the creation of the underlying used by the agent. + /// The to monitor for cancellation requests. The default is . + /// A instance that can be used to perform operations on the newly created agent. + /// Thrown when or or is . + /// Thrown when is empty or whitespace. + public static async Task CreateAIAgentAsync( + this PersistentAgentsClient persistentAgentsClient, + string model, + ChatClientAgentOptions options, + Func? clientFactory = null, + CancellationToken cancellationToken = default) + { + if (persistentAgentsClient is null) + { + throw new ArgumentNullException(nameof(persistentAgentsClient)); + } + + if (string.IsNullOrWhiteSpace(model)) + { + throw new ArgumentException($"{nameof(model)} should not be null or whitespace.", nameof(model)); + } + + if (options is null) + { + throw new ArgumentNullException(nameof(options)); + } + + var toolDefinitionsAndResources = ConvertAIToolsToToolDefinitions(options.ChatOptions?.Tools); + + var createPersistentAgentResponse = await persistentAgentsClient.Administration.CreateAgentAsync( + model: model, + name: options.Name, + description: options.Description, + instructions: options.Instructions, + tools: toolDefinitionsAndResources.ToolDefinitions, + toolResources: toolDefinitionsAndResources.ToolResources, + temperature: null, + topP: null, + responseFormat: null, + metadata: null, + cancellationToken: cancellationToken).ConfigureAwait(false); + + if (options.ChatOptions?.Tools is { Count: > 0 } && (toolDefinitionsAndResources.FunctionToolsAndOtherTools is null || options.ChatOptions.Tools.Count != toolDefinitionsAndResources.FunctionToolsAndOtherTools.Count)) + { + options = options.Clone(); + options.ChatOptions!.Tools = toolDefinitionsAndResources.FunctionToolsAndOtherTools; + } + + // Get a local proxy for the agent to work with. + return await persistentAgentsClient.GetAIAgentAsync(createPersistentAgentResponse.Value.Id, options, clientFactory: clientFactory, cancellationToken: cancellationToken).ConfigureAwait(false); + } + + private static (List? ToolDefinitions, ToolResources? ToolResources, List? FunctionToolsAndOtherTools) ConvertAIToolsToToolDefinitions(IList? tools) + { + List? toolDefinitions = null; + ToolResources? toolResources = null; + List? functionToolsAndOtherTools = null; + + if (tools is not null) + { + foreach (AITool tool in tools) + { + switch (tool) + { + case HostedCodeInterpreterTool codeTool: + + toolDefinitions ??= new(); + toolDefinitions.Add(new CodeInterpreterToolDefinition()); + + if (codeTool.Inputs is { Count: > 0 }) + { + foreach (var input in codeTool.Inputs) + { + switch (input) + { + case HostedFileContent hostedFile: + // If the input is a HostedFileContent, we can use its ID directly. + toolResources ??= new(); + toolResources.CodeInterpreter ??= new(); + toolResources.CodeInterpreter.FileIds.Add(hostedFile.FileId); + break; + } + } + } + break; + + case HostedFileSearchTool fileSearchTool: + toolDefinitions ??= new(); + toolDefinitions.Add(new FileSearchToolDefinition + { + FileSearch = new() { MaxNumResults = fileSearchTool.MaximumResultCount } + }); + + if (fileSearchTool.Inputs is { Count: > 0 }) + { + foreach (var input in fileSearchTool.Inputs) + { + switch (input) + { + case HostedVectorStoreContent hostedVectorStore: + toolResources ??= new(); + toolResources.FileSearch ??= new(); + toolResources.FileSearch.VectorStoreIds.Add(hostedVectorStore.VectorStoreId); + break; + } + } + } + break; + + case HostedWebSearchTool webSearch when webSearch.AdditionalProperties?.TryGetValue("connectionId", out object? connectionId) is true: + toolDefinitions ??= new(); + toolDefinitions.Add(new BingGroundingToolDefinition(new BingGroundingSearchToolParameters([new BingGroundingSearchConfiguration(connectionId!.ToString())]))); + break; + + default: + functionToolsAndOtherTools ??= new(); + functionToolsAndOtherTools.Add(tool); + break; + } + } + } + + return (toolDefinitions, toolResources, functionToolsAndOtherTools); + } } diff --git a/dotnet/src/Microsoft.Agents.AI.OpenAI/Extensions/OpenAIAssistantClientExtensions.cs b/dotnet/src/Microsoft.Agents.AI.OpenAI/Extensions/OpenAIAssistantClientExtensions.cs index 94410bb7d0..71f9b5436b 100644 --- a/dotnet/src/Microsoft.Agents.AI.OpenAI/Extensions/OpenAIAssistantClientExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.OpenAI/Extensions/OpenAIAssistantClientExtensions.cs @@ -143,6 +143,155 @@ public static class OpenAIAssistantClientExtensions return assistantClient.GetAIAgent(assistantResponse, chatOptions, clientFactory); } + /// + /// Gets a from a . + /// + /// The assistant client. + /// The client result containing the assistant. + /// Full set of options to configure the agent. + /// Provides a way to customize the creation of the underlying used by the agent. + /// A instance that can be used to perform operations on the assistant. + /// or is . + public static ChatClientAgent GetAIAgent( + this AssistantClient assistantClient, + ClientResult assistantClientResult, + ChatClientAgentOptions options, + Func? clientFactory = null) + { + if (assistantClientResult is null) + { + throw new ArgumentNullException(nameof(assistantClientResult)); + } + + return assistantClient.GetAIAgent(assistantClientResult.Value, options, clientFactory); + } + + /// + /// Gets a from an . + /// + /// The assistant client. + /// The assistant metadata. + /// Full set of options to configure the agent. + /// Provides a way to customize the creation of the underlying used by the agent. + /// A instance that can be used to perform operations on the assistant. + /// or is . + public static ChatClientAgent GetAIAgent( + this AssistantClient assistantClient, + Assistant assistantMetadata, + ChatClientAgentOptions options, + Func? clientFactory = null) + { + if (assistantMetadata is null) + { + throw new ArgumentNullException(nameof(assistantMetadata)); + } + + if (assistantClient is null) + { + throw new ArgumentNullException(nameof(assistantClient)); + } + + if (options is null) + { + throw new ArgumentNullException(nameof(options)); + } + + var chatClient = assistantClient.AsIChatClient(assistantMetadata.Id); + + if (clientFactory is not null) + { + chatClient = clientFactory(chatClient); + } + + var mergedOptions = new ChatClientAgentOptions() + { + Id = assistantMetadata.Id, + Name = options.Name ?? assistantMetadata.Name, + Description = options.Description ?? assistantMetadata.Description, + Instructions = options.Instructions ?? assistantMetadata.Instructions, + ChatOptions = options.ChatOptions, + AIContextProviderFactory = options.AIContextProviderFactory, + ChatMessageStoreFactory = options.ChatMessageStoreFactory, + UseProvidedChatClientAsIs = options.UseProvidedChatClientAsIs + }; + + return new ChatClientAgent(chatClient, mergedOptions); + } + + /// + /// Retrieves an existing server side agent, wrapped as a using the provided . + /// + /// The to create the with. + /// The ID of the server side agent to create a for. + /// Full set of options to configure the agent. + /// Provides a way to customize the creation of the underlying used by the agent. + /// The to monitor for cancellation requests. The default is . + /// A instance that can be used to perform operations on the assistant agent. + /// or is . + /// is empty or whitespace. + public static ChatClientAgent GetAIAgent( + this AssistantClient assistantClient, + string agentId, + ChatClientAgentOptions options, + Func? clientFactory = null, + CancellationToken cancellationToken = default) + { + if (assistantClient is null) + { + throw new ArgumentNullException(nameof(assistantClient)); + } + + if (string.IsNullOrWhiteSpace(agentId)) + { + throw new ArgumentException($"{nameof(agentId)} should not be null or whitespace.", nameof(agentId)); + } + + if (options is null) + { + throw new ArgumentNullException(nameof(options)); + } + + var assistant = assistantClient.GetAssistant(agentId, cancellationToken); + return assistantClient.GetAIAgent(assistant, options, clientFactory); + } + + /// + /// Retrieves an existing server side agent, wrapped as a using the provided . + /// + /// The to create the with. + /// The ID of the server side agent to create a for. + /// Full set of options to configure the agent. + /// Provides a way to customize the creation of the underlying used by the agent. + /// The to monitor for cancellation requests. The default is . + /// A instance that can be used to perform operations on the assistant agent. + /// or is . + /// is empty or whitespace. + public static async Task GetAIAgentAsync( + this AssistantClient assistantClient, + string agentId, + ChatClientAgentOptions options, + Func? clientFactory = null, + CancellationToken cancellationToken = default) + { + if (assistantClient is null) + { + throw new ArgumentNullException(nameof(assistantClient)); + } + + if (string.IsNullOrWhiteSpace(agentId)) + { + throw new ArgumentException($"{nameof(agentId)} should not be null or whitespace.", nameof(agentId)); + } + + if (options is null) + { + throw new ArgumentNullException(nameof(options)); + } + + var assistantResponse = await assistantClient.GetAssistantAsync(agentId, cancellationToken).ConfigureAwait(false); + return assistantClient.GetAIAgent(assistantResponse, options, clientFactory); + } + /// /// Creates an AI agent from an using the OpenAI Assistant API. /// @@ -210,48 +359,34 @@ public static class OpenAIAssistantClientExtensions Instructions = options.Instructions, }; - if (options.ChatOptions?.Tools is not null) + // Convert AITools to ToolDefinitions and ToolResources + var toolDefinitionsAndResources = ConvertAIToolsToToolDefinitions(options.ChatOptions?.Tools); + if (toolDefinitionsAndResources.ToolDefinitions is { Count: > 0 }) { - foreach (AITool tool in options.ChatOptions.Tools) - { - switch (tool) - { - // Attempting to set the tools at the agent level throws - // https://github.com/dotnet/extensions/issues/6743 - //case AIFunction aiFunction: - // assistantOptions.Tools.Add(ToOpenAIAssistantsFunctionToolDefinition(aiFunction)); - // break; - - case HostedCodeInterpreterTool: - var codeInterpreterToolDefinition = new CodeInterpreterToolDefinition(); - assistantOptions.Tools.Add(codeInterpreterToolDefinition); - break; - } - } + toolDefinitionsAndResources.ToolDefinitions.ForEach(x => assistantOptions.Tools.Add(x)); } + if (toolDefinitionsAndResources.ToolResources is not null) + { + assistantOptions.ToolResources = toolDefinitionsAndResources.ToolResources; + } + + // Create the assistant in the assistant service. var assistantCreateResult = client.CreateAssistant(model, assistantOptions); var assistantId = assistantCreateResult.Value.Id; - var agentOptions = new ChatClientAgentOptions() - { - Id = assistantId, - Name = options.Name, - Description = options.Description, - Instructions = options.Instructions, - ChatOptions = options.ChatOptions?.Tools is null ? null : new ChatOptions() - { - Tools = options.ChatOptions.Tools, - } - }; - + // Build the local agent object. var chatClient = client.AsIChatClient(assistantId); - if (clientFactory is not null) { chatClient = clientFactory(chatClient); } + var agentOptions = options.Clone(); + agentOptions.Id = assistantId; + options.ChatOptions ??= new ChatOptions(); + options.ChatOptions!.Tools = toolDefinitionsAndResources.FunctionToolsAndOtherTools; + return new ChatClientAgent(chatClient, agentOptions, loggerFactory); } @@ -321,48 +456,101 @@ public static class OpenAIAssistantClientExtensions Instructions = options.Instructions, }; - if (options.ChatOptions?.Tools is not null) + // Convert AITools to ToolDefinitions and ToolResources + var toolDefinitionsAndResources = ConvertAIToolsToToolDefinitions(options.ChatOptions?.Tools); + if (toolDefinitionsAndResources.ToolDefinitions is { Count: > 0 } toolDefinitions) { - foreach (AITool tool in options.ChatOptions.Tools) - { - switch (tool) - { - // Attempting to set the tools at the agent level throws - // https://github.com/dotnet/extensions/issues/6743 - //case AIFunction aiFunction: - // assistantOptions.Tools.Add(ToOpenAIAssistantsFunctionToolDefinition(aiFunction)); - // break; - - case HostedCodeInterpreterTool: - var codeInterpreterToolDefinition = new CodeInterpreterToolDefinition(); - assistantOptions.Tools.Add(codeInterpreterToolDefinition); - break; - } - } + toolDefinitions.ForEach(x => assistantOptions.Tools.Add(x)); + } + if (toolDefinitionsAndResources.ToolResources is not null) + { + assistantOptions.ToolResources = toolDefinitionsAndResources.ToolResources; } + // Create the assistant in the assistant service. var assistantCreateResult = await client.CreateAssistantAsync(model, assistantOptions).ConfigureAwait(false); var assistantId = assistantCreateResult.Value.Id; - var agentOptions = new ChatClientAgentOptions() - { - Id = assistantId, - Name = options.Name, - Description = options.Description, - Instructions = options.Instructions, - ChatOptions = options.ChatOptions?.Tools is null ? null : new ChatOptions() - { - Tools = options.ChatOptions.Tools, - } - }; - + // Build the local agent object. var chatClient = client.AsIChatClient(assistantId); - if (clientFactory is not null) { chatClient = clientFactory(chatClient); } + var agentOptions = options.Clone(); + agentOptions.Id = assistantId; + options.ChatOptions ??= new ChatOptions(); + options.ChatOptions!.Tools = toolDefinitionsAndResources.FunctionToolsAndOtherTools; + return new ChatClientAgent(chatClient, agentOptions, loggerFactory); } + + private static (List? ToolDefinitions, ToolResources? ToolResources, List? FunctionToolsAndOtherTools) ConvertAIToolsToToolDefinitions(IList? tools) + { + List? toolDefinitions = null; + ToolResources? toolResources = null; + List? functionToolsAndOtherTools = null; + + if (tools is not null) + { + foreach (AITool tool in tools) + { + switch (tool) + { + case HostedCodeInterpreterTool codeTool: + + toolDefinitions ??= new(); + toolDefinitions.Add(new CodeInterpreterToolDefinition()); + + if (codeTool.Inputs is { Count: > 0 }) + { + foreach (var input in codeTool.Inputs) + { + switch (input) + { + case HostedFileContent hostedFile: + // If the input is a HostedFileContent, we can use its ID directly. + toolResources ??= new(); + toolResources.CodeInterpreter ??= new(); + toolResources.CodeInterpreter.FileIds.Add(hostedFile.FileId); + break; + } + } + } + break; + + case HostedFileSearchTool fileSearchTool: + toolDefinitions ??= new(); + toolDefinitions.Add(new FileSearchToolDefinition + { + MaxResults = fileSearchTool.MaximumResultCount, + }); + + if (fileSearchTool.Inputs is { Count: > 0 }) + { + foreach (var input in fileSearchTool.Inputs) + { + switch (input) + { + case HostedVectorStoreContent hostedVectorStore: + toolResources ??= new(); + toolResources.FileSearch ??= new(); + toolResources.FileSearch.VectorStoreIds.Add(hostedVectorStore.VectorStoreId); + break; + } + } + } + break; + + default: + functionToolsAndOtherTools ??= new(); + functionToolsAndOtherTools.Add(tool); + break; + } + } + } + + return (toolDefinitions, toolResources, functionToolsAndOtherTools); + } } diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentOptions.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentOptions.cs index aede5ef3cb..f83e6912d5 100644 --- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentOptions.cs @@ -101,7 +101,7 @@ public class ChatClientAgentOptions /// /// Creates a new instance of with the same values as this instance. /// - internal ChatClientAgentOptions Clone() + public ChatClientAgentOptions Clone() => new() { Id = this.Id, diff --git a/dotnet/tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsPersistent.IntegrationTests.csproj b/dotnet/tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsPersistent.IntegrationTests.csproj index c53ca9de0f..f5cafd2975 100644 --- a/dotnet/tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsPersistent.IntegrationTests.csproj +++ b/dotnet/tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsPersistent.IntegrationTests.csproj @@ -7,6 +7,7 @@ + diff --git a/dotnet/tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsPersistentCreateTests.cs b/dotnet/tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsPersistentCreateTests.cs new file mode 100644 index 0000000000..32b51b4196 --- /dev/null +++ b/dotnet/tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsPersistentCreateTests.cs @@ -0,0 +1,262 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.IO; +using System.Threading.Tasks; +using AgentConformance.IntegrationTests.Support; +using Azure.AI.Agents.Persistent; +using Azure.Identity; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using Shared.IntegrationTests; + +namespace AzureAIAgentsPersistent.IntegrationTests; + +public class AzureAIAgentsPersistentCreateTests +{ + private static readonly AzureAIConfiguration s_config = TestConfiguration.LoadSection(); + private readonly PersistentAgentsClient _persistentAgentsClient = new(s_config.Endpoint, new AzureCliCredential()); + + [Theory] + [InlineData("CreateWithChatClientAgentOptionsAsync")] + [InlineData("CreateWithChatClientAgentOptionsSync")] + [InlineData("CreateWithFoundryOptionsAsync")] + [InlineData("CreateWithFoundryOptionsSync")] + public async Task CreateAgent_CreatesAgentWithCorrectMetadataAsync(string createMechanism) + { + // Arrange. + const string AgentName = "IntegrationTestAgent"; + const string AgentDescription = "An agent created during integration tests"; + const string AgentInstructions = "You are an integration test agent"; + + // Act. + var agent = createMechanism switch + { + "CreateWithChatClientAgentOptionsAsync" => await this._persistentAgentsClient.CreateAIAgentAsync( + s_config.DeploymentName, + options: new ChatClientAgentOptions( + instructions: AgentInstructions, + name: AgentName, + description: AgentDescription)), + "CreateWithChatClientAgentOptionsSync" => this._persistentAgentsClient.CreateAIAgent( + s_config.DeploymentName, + options: new ChatClientAgentOptions( + instructions: AgentInstructions, + name: AgentName, + description: AgentDescription)), + "CreateWithFoundryOptionsAsync" => await this._persistentAgentsClient.CreateAIAgentAsync( + s_config.DeploymentName, + instructions: AgentInstructions, + name: AgentName, + description: AgentDescription), + "CreateWithFoundryOptionsSync" => this._persistentAgentsClient.CreateAIAgent( + s_config.DeploymentName, + instructions: AgentInstructions, + name: AgentName, + description: AgentDescription), + _ => throw new InvalidOperationException($"Unknown create mechanism: {createMechanism}") + }; + + try + { + // Assert. + Assert.NotNull(agent); + Assert.Equal(AgentName, agent.Name); + Assert.Equal(AgentDescription, agent.Description); + Assert.Equal(AgentInstructions, agent.Instructions); + + var retrievedAgentMetadata = await this._persistentAgentsClient.Administration.GetAgentAsync(agent.Id); + Assert.NotNull(retrievedAgentMetadata); + Assert.Equal(AgentName, retrievedAgentMetadata.Value.Name); + Assert.Equal(AgentDescription, retrievedAgentMetadata.Value.Description); + Assert.Equal(AgentInstructions, retrievedAgentMetadata.Value.Instructions); + } + finally + { + // Cleanup. + await this._persistentAgentsClient.Administration.DeleteAgentAsync(agent.Id); + } + } + + [Theory] + [InlineData("CreateWithChatClientAgentOptionsAsync")] + [InlineData("CreateWithChatClientAgentOptionsSync")] + [InlineData("CreateWithFoundryOptionsAsync")] + [InlineData("CreateWithFoundryOptionsSync")] + public async Task CreateAgent_CreatesAgentWithVectorStoresAsync(string createMechanism) + { + // Arrange. + const string AgentInstructions = """ + You are a helpful agent that can help fetch data from files you know about. + Use the File Search Tool to look up codes for words. + Do not answer a question unless you can find the answer using the File Search Tool. + """; + + // Create a vector store. + var searchFilePath = Path.GetTempFileName() + "wordcodelookup.txt"; + File.WriteAllText( + path: searchFilePath, + contents: "The word 'apple' uses the code 442345, while the word 'banana' uses the code 673457." + ); + PersistentAgentFileInfo uploadedAgentFile = this._persistentAgentsClient.Files.UploadFile( + filePath: searchFilePath, + purpose: PersistentAgentFilePurpose.Agents + ); + var vectorStoreMetadata = await this._persistentAgentsClient.VectorStores.CreateVectorStoreAsync([uploadedAgentFile.Id], name: "WordCodeLookup_VectorStore"); + + // Act. + var agent = createMechanism switch + { + "CreateWithChatClientAgentOptionsAsync" => await this._persistentAgentsClient.CreateAIAgentAsync( + s_config.DeploymentName, + options: new ChatClientAgentOptions( + instructions: AgentInstructions, + tools: [new HostedFileSearchTool() { Inputs = [new HostedVectorStoreContent(vectorStoreMetadata.Value.Id)] }])), + "CreateWithChatClientAgentOptionsSync" => this._persistentAgentsClient.CreateAIAgent( + s_config.DeploymentName, + options: new ChatClientAgentOptions( + instructions: AgentInstructions, + tools: [new HostedFileSearchTool() { Inputs = [new HostedVectorStoreContent(vectorStoreMetadata.Value.Id)] }])), + "CreateWithFoundryOptionsAsync" => await this._persistentAgentsClient.CreateAIAgentAsync( + s_config.DeploymentName, + instructions: AgentInstructions, + tools: [new FileSearchToolDefinition()], + toolResources: new ToolResources() { FileSearch = new([vectorStoreMetadata.Value.Id], null) }), + "CreateWithFoundryOptionsSync" => this._persistentAgentsClient.CreateAIAgent( + s_config.DeploymentName, + instructions: AgentInstructions, + tools: [new FileSearchToolDefinition()], + toolResources: new ToolResources() { FileSearch = new([vectorStoreMetadata.Value.Id], null) }), + _ => throw new InvalidOperationException($"Unknown create mechanism: {createMechanism}") + }; + + try + { + // Assert. + // Verify that the agent can use the vector store to answer a question. + var result = await agent.RunAsync("Can you give me the documented code for 'banana'?"); + Assert.Contains("673457", result.ToString()); + } + finally + { + // Cleanup. + await this._persistentAgentsClient.Administration.DeleteAgentAsync(agent.Id); + await this._persistentAgentsClient.VectorStores.DeleteVectorStoreAsync(vectorStoreMetadata.Value.Id); + await this._persistentAgentsClient.Files.DeleteFileAsync(uploadedAgentFile.Id); + File.Delete(searchFilePath); + } + } + + [Theory] + [InlineData("CreateWithChatClientAgentOptionsAsync")] + [InlineData("CreateWithChatClientAgentOptionsSync")] + [InlineData("CreateWithFoundryOptionsAsync")] + [InlineData("CreateWithFoundryOptionsSync")] + public async Task CreateAgent_CreatesAgentWithCodeInterpreterAsync(string createMechanism) + { + // Arrange. + const string AgentInstructions = """ + You are a helpful coding agent. A Python file is provided. Use the Code Interpreter Tool to run the file + and report the SECRET_NUMBER value it prints. Respond only with the number. + """; + + // Create a python file that prints a known value. + var codeFilePath = Path.GetTempFileName() + "secret_number.py"; + File.WriteAllText( + path: codeFilePath, + contents: "print(\"SECRET_NUMBER=24601\")" // Deterministic output we will look for. + ); + PersistentAgentFileInfo uploadedCodeFile = this._persistentAgentsClient.Files.UploadFile( + filePath: codeFilePath, + purpose: PersistentAgentFilePurpose.Agents + ); + CodeInterpreterToolResource toolResource = new(); + toolResource.FileIds.Add(uploadedCodeFile.Id); + + // Act. + var agent = createMechanism switch + { + // Hosted tool path (tools supplied via ChatClientAgentOptions) + "CreateWithChatClientAgentOptionsAsync" => await this._persistentAgentsClient.CreateAIAgentAsync( + s_config.DeploymentName, + options: new ChatClientAgentOptions( + instructions: AgentInstructions, + tools: [new HostedCodeInterpreterTool() { Inputs = [new HostedFileContent(uploadedCodeFile.Id)] }])), + "CreateWithChatClientAgentOptionsSync" => this._persistentAgentsClient.CreateAIAgent( + s_config.DeploymentName, + options: new ChatClientAgentOptions( + instructions: AgentInstructions, + tools: [new HostedCodeInterpreterTool() { Inputs = [new HostedFileContent(uploadedCodeFile.Id)] }])), + // Foundry (definitions + resources provided directly) + "CreateWithFoundryOptionsAsync" => await this._persistentAgentsClient.CreateAIAgentAsync( + s_config.DeploymentName, + instructions: AgentInstructions, + tools: [new CodeInterpreterToolDefinition()], + toolResources: new ToolResources() { CodeInterpreter = toolResource }), + "CreateWithFoundryOptionsSync" => this._persistentAgentsClient.CreateAIAgent( + s_config.DeploymentName, + instructions: AgentInstructions, + tools: [new CodeInterpreterToolDefinition()], + toolResources: new ToolResources() { CodeInterpreter = toolResource }), + _ => throw new InvalidOperationException($"Unknown create mechanism: {createMechanism}") + }; + + try + { + // Assert. + var result = await agent.RunAsync("What is the SECRET_NUMBER?"); + // We expect the model to run the code and surface the number. + Assert.Contains("24601", result.ToString()); + } + finally + { + // Cleanup. + await this._persistentAgentsClient.Administration.DeleteAgentAsync(agent.Id); + await this._persistentAgentsClient.Files.DeleteFileAsync(uploadedCodeFile.Id); + File.Delete(codeFilePath); + } + } + + [Theory] + [InlineData("CreateWithChatClientAgentOptionsAsync")] + [InlineData("CreateWithChatClientAgentOptionsSync")] + public async Task CreateAgent_CreatesAgentWithAIFunctionToolsAsync(string createMechanism) + { + // Arrange. + const string AgentInstructions = "You are a helpful weather assistant. Always call the GetWeather function to answer questions about weather."; + + static string GetWeather(string location) => $"The weather in {location} is sunny with a high of 23C."; + var weatherFunction = AIFunctionFactory.Create(GetWeather); + + ChatClientAgent agent = createMechanism switch + { + "CreateWithChatClientAgentOptionsAsync" => await this._persistentAgentsClient.CreateAIAgentAsync( + s_config.DeploymentName, + options: new ChatClientAgentOptions( + instructions: AgentInstructions, + tools: [weatherFunction])), + "CreateWithChatClientAgentOptionsSync" => this._persistentAgentsClient.CreateAIAgent( + s_config.DeploymentName, + options: new ChatClientAgentOptions( + instructions: AgentInstructions, + tools: [weatherFunction])), + _ => throw new InvalidOperationException($"Unknown create mechanism: {createMechanism}") + }; + + try + { + // Act. + var response = await agent.RunAsync("What is the weather like in Amsterdam?"); + + // Assert - ensure function was invoked and its output surfaced. + var text = response.Text; + Assert.Contains("Amsterdam", text, StringComparison.OrdinalIgnoreCase); + Assert.Contains("sunny", text, StringComparison.OrdinalIgnoreCase); + Assert.Contains("23", text, StringComparison.OrdinalIgnoreCase); + } + finally + { + await this._persistentAgentsClient.Administration.DeleteAgentAsync(agent.Id); + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/Extensions/PersistentAgentsClientExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/Extensions/PersistentAgentsClientExtensionsTests.cs index 705136ba0e..2405cd3347 100644 --- a/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/Extensions/PersistentAgentsClientExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/Extensions/PersistentAgentsClientExtensionsTests.cs @@ -294,6 +294,438 @@ public sealed class PersistentAgentsClientExtensionsTests Assert.Null(retrievedTestClient); } + /// + /// Verify that GetAIAgent with Response and options works correctly. + /// + [Fact] + public void GetAIAgent_WithResponseAndOptions_WorksCorrectly() + { + // Arrange + var client = CreateFakePersistentAgentsClient(); + var persistentAgent = ModelReaderWriter.Read(BinaryData.FromString("""{"id": "agent_abc123", "name": "Original Name", "description": "Original Description", "instructions": "Original Instructions"}"""))!; + var response = Response.FromValue(persistentAgent, new FakeResponse()); + + var options = new ChatClientAgentOptions + { + Name = "Override Name", + Description = "Override Description", + Instructions = "Override Instructions" + }; + + // Act + var agent = client.GetAIAgent(response, options); + + // Assert + Assert.NotNull(agent); + Assert.Equal("Override Name", agent.Name); + Assert.Equal("Override Description", agent.Description); + Assert.Equal("Override Instructions", agent.Instructions); + } + + /// + /// Verify that GetAIAgent with PersistentAgent and options works correctly. + /// + [Fact] + public void GetAIAgent_WithPersistentAgentAndOptions_WorksCorrectly() + { + // Arrange + var client = CreateFakePersistentAgentsClient(); + var persistentAgent = ModelReaderWriter.Read(BinaryData.FromString("""{"id": "agent_abc123", "name": "Original Name", "description": "Original Description", "instructions": "Original Instructions"}"""))!; + + var options = new ChatClientAgentOptions + { + Name = "Override Name", + Description = "Override Description", + Instructions = "Override Instructions" + }; + + // Act + var agent = client.GetAIAgent(persistentAgent, options); + + // Assert + Assert.NotNull(agent); + Assert.Equal("Override Name", agent.Name); + Assert.Equal("Override Description", agent.Description); + Assert.Equal("Override Instructions", agent.Instructions); + } + + /// + /// Verify that GetAIAgent with PersistentAgent and options falls back to agent metadata when options are null. + /// + [Fact] + public void GetAIAgent_WithPersistentAgentAndOptionsWithNullFields_FallsBackToAgentMetadata() + { + // Arrange + var client = CreateFakePersistentAgentsClient(); + var persistentAgent = ModelReaderWriter.Read(BinaryData.FromString("""{"id": "agent_abc123", "name": "Original Name", "description": "Original Description", "instructions": "Original Instructions"}"""))!; + + var options = new ChatClientAgentOptions(); // Empty options + + // Act + var agent = client.GetAIAgent(persistentAgent, options); + + // Assert + Assert.NotNull(agent); + Assert.Equal("Original Name", agent.Name); + Assert.Equal("Original Description", agent.Description); + Assert.Equal("Original Instructions", agent.Instructions); + } + + /// + /// Verify that GetAIAgent with agentId and options works correctly. + /// + [Fact] + public void GetAIAgent_WithAgentIdAndOptions_WorksCorrectly() + { + // Arrange + var client = CreateFakePersistentAgentsClient(); + const string AgentId = "agent_abc123"; + + var options = new ChatClientAgentOptions + { + Name = "Override Name", + Description = "Override Description", + Instructions = "Override Instructions" + }; + + // Act + var agent = client.GetAIAgent(AgentId, options); + + // Assert + Assert.NotNull(agent); + Assert.Equal("Override Name", agent.Name); + Assert.Equal("Override Description", agent.Description); + Assert.Equal("Override Instructions", agent.Instructions); + } + + /// + /// Verify that GetAIAgentAsync with agentId and options works correctly. + /// + [Fact] + public async Task GetAIAgentAsync_WithAgentIdAndOptions_WorksCorrectlyAsync() + { + // Arrange + var client = CreateFakePersistentAgentsClient(); + const string AgentId = "agent_abc123"; + + var options = new ChatClientAgentOptions + { + Name = "Override Name", + Description = "Override Description", + Instructions = "Override Instructions" + }; + + // Act + var agent = await client.GetAIAgentAsync(AgentId, options); + + // Assert + Assert.NotNull(agent); + Assert.Equal("Override Name", agent.Name); + Assert.Equal("Override Description", agent.Description); + Assert.Equal("Override Instructions", agent.Instructions); + } + + /// + /// Verify that GetAIAgent with clientFactory parameter correctly applies the factory. + /// + [Fact] + public void GetAIAgent_WithOptionsAndClientFactory_AppliesFactoryCorrectly() + { + // Arrange + var client = CreateFakePersistentAgentsClient(); + var persistentAgent = ModelReaderWriter.Read(BinaryData.FromString("""{"id": "agent_abc123", "name": "Test Agent"}"""))!; + var testChatClient = new TestChatClient(client.AsIChatClient("agent_abc123")); + + var options = new ChatClientAgentOptions + { + Name = "Test Agent" + }; + + // Act + var agent = client.GetAIAgent( + persistentAgent, + options, + clientFactory: (innerClient) => testChatClient); + + // Assert + Assert.NotNull(agent); + Assert.Equal("Test Agent", agent.Name); + + // Verify that the custom chat client can be retrieved from the agent's service collection + var retrievedTestClient = agent.GetService(); + Assert.NotNull(retrievedTestClient); + Assert.Same(testChatClient, retrievedTestClient); + } + + /// + /// Verify that GetAIAgent throws ArgumentNullException when response is null. + /// + [Fact] + public void GetAIAgent_WithNullResponse_ThrowsArgumentNullException() + { + // Arrange + var client = CreateFakePersistentAgentsClient(); + var options = new ChatClientAgentOptions(); + + // Act & Assert + var exception = Assert.Throws(() => + client.GetAIAgent((Response)null!, options)); + + Assert.Equal("persistentAgentResponse", exception.ParamName); + } + + /// + /// Verify that GetAIAgent throws ArgumentNullException when persistentAgent is null. + /// + [Fact] + public void GetAIAgent_WithNullPersistentAgent_ThrowsArgumentNullException() + { + // Arrange + var client = CreateFakePersistentAgentsClient(); + var options = new ChatClientAgentOptions(); + + // Act & Assert + var exception = Assert.Throws(() => + client.GetAIAgent((PersistentAgent)null!, options)); + + Assert.Equal("persistentAgentMetadata", exception.ParamName); + } + + /// + /// Verify that GetAIAgent throws ArgumentNullException when options is null. + /// + [Fact] + public void GetAIAgent_WithNullOptions_ThrowsArgumentNullException() + { + // Arrange + var client = CreateFakePersistentAgentsClient(); + var persistentAgent = ModelReaderWriter.Read(BinaryData.FromString("""{"id": "agent_abc123"}"""))!; + + // Act & Assert + var exception = Assert.Throws(() => + client.GetAIAgent(persistentAgent, (ChatClientAgentOptions)null!)); + + Assert.Equal("options", exception.ParamName); + } + + /// + /// Verify that GetAIAgent throws ArgumentException when agentId is empty. + /// + [Fact] + public void GetAIAgent_WithOptionsAndEmptyAgentId_ThrowsArgumentException() + { + // Arrange + var client = CreateFakePersistentAgentsClient(); + var options = new ChatClientAgentOptions(); + + // Act & Assert + var exception = Assert.Throws(() => + client.GetAIAgent(string.Empty, options)); + + Assert.Equal("agentId", exception.ParamName); + } + + /// + /// Verify that GetAIAgentAsync throws ArgumentException when agentId is empty. + /// + [Fact] + public async Task GetAIAgentAsync_WithOptionsAndEmptyAgentId_ThrowsArgumentExceptionAsync() + { + // Arrange + var client = CreateFakePersistentAgentsClient(); + var options = new ChatClientAgentOptions(); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + client.GetAIAgentAsync(string.Empty, options)); + + Assert.Equal("agentId", exception.ParamName); + } + + /// + /// Verify that CreateAIAgent with options works correctly. + /// + [Fact] + public void CreateAIAgent_WithOptions_WorksCorrectly() + { + // Arrange + var client = CreateFakePersistentAgentsClient(); + const string Model = "test-model"; + + var options = new ChatClientAgentOptions + { + Name = "Test Agent", + Description = "Test description", + Instructions = "Test instructions" + }; + + // Act + var agent = client.CreateAIAgent(Model, options); + + // Assert + Assert.NotNull(agent); + Assert.Equal("Test Agent", agent.Name); + Assert.Equal("Test description", agent.Description); + Assert.Equal("Test instructions", agent.Instructions); + } + + /// + /// Verify that CreateAIAgentAsync with options works correctly. + /// + [Fact] + public async Task CreateAIAgentAsync_WithOptions_WorksCorrectlyAsync() + { + // Arrange + var client = CreateFakePersistentAgentsClient(); + const string Model = "test-model"; + + var options = new ChatClientAgentOptions + { + Name = "Test Agent", + Description = "Test description", + Instructions = "Test instructions" + }; + + // Act + var agent = await client.CreateAIAgentAsync(Model, options); + + // Assert + Assert.NotNull(agent); + Assert.Equal("Test Agent", agent.Name); + Assert.Equal("Test description", agent.Description); + Assert.Equal("Test instructions", agent.Instructions); + } + + /// + /// Verify that CreateAIAgent with options and clientFactory applies the factory correctly. + /// + [Fact] + public void CreateAIAgent_WithOptionsAndClientFactory_AppliesFactoryCorrectly() + { + // Arrange + var client = CreateFakePersistentAgentsClient(); + TestChatClient? testChatClient = null; + const string Model = "test-model"; + + var options = new ChatClientAgentOptions + { + Name = "Test Agent" + }; + + // Act + var agent = client.CreateAIAgent( + Model, + options, + clientFactory: (innerClient) => testChatClient = new TestChatClient(innerClient)); + + // Assert + Assert.NotNull(agent); + Assert.Equal("Test Agent", agent.Name); + + // Verify that the custom chat client can be retrieved from the agent's service collection + var retrievedTestClient = agent.GetService(); + Assert.NotNull(retrievedTestClient); + Assert.Same(testChatClient, retrievedTestClient); + } + + /// + /// Verify that CreateAIAgentAsync with options and clientFactory applies the factory correctly. + /// + [Fact] + public async Task CreateAIAgentAsync_WithOptionsAndClientFactory_AppliesFactoryCorrectlyAsync() + { + // Arrange + var client = CreateFakePersistentAgentsClient(); + TestChatClient? testChatClient = null; + const string Model = "test-model"; + + var options = new ChatClientAgentOptions + { + Name = "Test Agent" + }; + + // Act + var agent = await client.CreateAIAgentAsync( + Model, + options, + clientFactory: (innerClient) => testChatClient = new TestChatClient(innerClient)); + + // Assert + Assert.NotNull(agent); + Assert.Equal("Test Agent", agent.Name); + + // Verify that the custom chat client can be retrieved from the agent's service collection + var retrievedTestClient = agent.GetService(); + Assert.NotNull(retrievedTestClient); + Assert.Same(testChatClient, retrievedTestClient); + } + + /// + /// Verify that CreateAIAgent throws ArgumentNullException when options is null. + /// + [Fact] + public void CreateAIAgent_WithNullOptions_ThrowsArgumentNullException() + { + // Arrange + var client = CreateFakePersistentAgentsClient(); + + // Act & Assert + var exception = Assert.Throws(() => + client.CreateAIAgent("test-model", (ChatClientAgentOptions)null!)); + + Assert.Equal("options", exception.ParamName); + } + + /// + /// Verify that CreateAIAgentAsync throws ArgumentNullException when options is null. + /// + [Fact] + public async Task CreateAIAgentAsync_WithNullOptions_ThrowsArgumentNullExceptionAsync() + { + // Arrange + var client = CreateFakePersistentAgentsClient(); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + client.CreateAIAgentAsync("test-model", (ChatClientAgentOptions)null!)); + + Assert.Equal("options", exception.ParamName); + } + + /// + /// Verify that CreateAIAgent throws ArgumentException when model is empty. + /// + [Fact] + public void CreateAIAgent_WithEmptyModel_ThrowsArgumentException() + { + // Arrange + var client = CreateFakePersistentAgentsClient(); + var options = new ChatClientAgentOptions(); + + // Act & Assert + var exception = Assert.Throws(() => + client.CreateAIAgent(string.Empty, options)); + + Assert.Equal("model", exception.ParamName); + } + + /// + /// Verify that CreateAIAgentAsync throws ArgumentException when model is empty. + /// + [Fact] + public async Task CreateAIAgentAsync_WithEmptyModel_ThrowsArgumentExceptionAsync() + { + // Arrange + var client = CreateFakePersistentAgentsClient(); + var options = new ChatClientAgentOptions(); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + client.CreateAIAgentAsync(string.Empty, options)); + + Assert.Equal("model", exception.ParamName); + } + /// /// Test custom chat client that can be used to verify clientFactory functionality. /// diff --git a/dotnet/tests/Microsoft.Agents.AI.OpenAI.UnitTests/Extensions/OpenAIAssistantClientExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.OpenAI.UnitTests/Extensions/OpenAIAssistantClientExtensionsTests.cs index 743cadabc4..61e3f5ef57 100644 --- a/dotnet/tests/Microsoft.Agents.AI.OpenAI.UnitTests/Extensions/OpenAIAssistantClientExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.OpenAI.UnitTests/Extensions/OpenAIAssistantClientExtensionsTests.cs @@ -207,6 +207,254 @@ public sealed class OpenAIAssistantClientExtensionsTests Assert.Equal("options", exception.ParamName); } + /// + /// Verify that GetAIAgent with ClientResult and options works correctly. + /// + [Fact] + public void GetAIAgent_WithClientResultAndOptions_WorksCorrectly() + { + // Arrange + var assistantClient = new TestAssistantClient(); + var assistant = ModelReaderWriter.Read(BinaryData.FromString("""{"id": "asst_abc123", "name": "Original Name", "description": "Original Description", "instructions": "Original Instructions"}"""))!; + var clientResult = ClientResult.FromValue(assistant, new FakePipelineResponse()); + + var options = new ChatClientAgentOptions + { + Name = "Override Name", + Description = "Override Description", + Instructions = "Override Instructions" + }; + + // Act + var agent = assistantClient.GetAIAgent(clientResult, options); + + // Assert + Assert.NotNull(agent); + Assert.Equal("Override Name", agent.Name); + Assert.Equal("Override Description", agent.Description); + Assert.Equal("Override Instructions", agent.Instructions); + } + + /// + /// Verify that GetAIAgent with Assistant and options works correctly. + /// + [Fact] + public void GetAIAgent_WithAssistantAndOptions_WorksCorrectly() + { + // Arrange + var assistantClient = new TestAssistantClient(); + var assistant = ModelReaderWriter.Read(BinaryData.FromString("""{"id": "asst_abc123", "name": "Original Name", "description": "Original Description", "instructions": "Original Instructions"}"""))!; + + var options = new ChatClientAgentOptions + { + Name = "Override Name", + Description = "Override Description", + Instructions = "Override Instructions" + }; + + // Act + var agent = assistantClient.GetAIAgent(assistant, options); + + // Assert + Assert.NotNull(agent); + Assert.Equal("Override Name", agent.Name); + Assert.Equal("Override Description", agent.Description); + Assert.Equal("Override Instructions", agent.Instructions); + } + + /// + /// Verify that GetAIAgent with Assistant and options falls back to assistant metadata when options are null. + /// + [Fact] + public void GetAIAgent_WithAssistantAndOptionsWithNullFields_FallsBackToAssistantMetadata() + { + // Arrange + var assistantClient = new TestAssistantClient(); + var assistant = ModelReaderWriter.Read(BinaryData.FromString("""{"id": "asst_abc123", "name": "Original Name", "description": "Original Description", "instructions": "Original Instructions"}"""))!; + + var options = new ChatClientAgentOptions(); // Empty options + + // Act + var agent = assistantClient.GetAIAgent(assistant, options); + + // Assert + Assert.NotNull(agent); + Assert.Equal("Original Name", agent.Name); + Assert.Equal("Original Description", agent.Description); + Assert.Equal("Original Instructions", agent.Instructions); + } + + /// + /// Verify that GetAIAgent with agentId and options works correctly. + /// + [Fact] + public void GetAIAgent_WithAgentIdAndOptions_WorksCorrectly() + { + // Arrange + var assistantClient = new TestAssistantClient(); + const string AgentId = "asst_abc123"; + + var options = new ChatClientAgentOptions + { + Name = "Override Name", + Description = "Override Description", + Instructions = "Override Instructions" + }; + + // Act + var agent = assistantClient.GetAIAgent(AgentId, options); + + // Assert + Assert.NotNull(agent); + Assert.Equal("Override Name", agent.Name); + Assert.Equal("Override Description", agent.Description); + Assert.Equal("Override Instructions", agent.Instructions); + } + + /// + /// Verify that GetAIAgentAsync with agentId and options works correctly. + /// + [Fact] + public async Task GetAIAgentAsync_WithAgentIdAndOptions_WorksCorrectlyAsync() + { + // Arrange + var assistantClient = new TestAssistantClient(); + const string AgentId = "asst_abc123"; + + var options = new ChatClientAgentOptions + { + Name = "Override Name", + Description = "Override Description", + Instructions = "Override Instructions" + }; + + // Act + var agent = await assistantClient.GetAIAgentAsync(AgentId, options); + + // Assert + Assert.NotNull(agent); + Assert.Equal("Override Name", agent.Name); + Assert.Equal("Override Description", agent.Description); + Assert.Equal("Override Instructions", agent.Instructions); + } + + /// + /// Verify that GetAIAgent with clientFactory parameter correctly applies the factory. + /// + [Fact] + public void GetAIAgent_WithClientFactory_AppliesFactoryCorrectly() + { + // Arrange + var assistantClient = new TestAssistantClient(); + var assistant = ModelReaderWriter.Read(BinaryData.FromString("""{"id": "asst_abc123", "name": "Test Agent"}"""))!; + var testChatClient = new TestChatClient(assistantClient.AsIChatClient("asst_abc123")); + + var options = new ChatClientAgentOptions + { + Name = "Test Agent" + }; + + // Act + var agent = assistantClient.GetAIAgent( + assistant, + options, + clientFactory: (innerClient) => testChatClient); + + // Assert + Assert.NotNull(agent); + Assert.Equal("Test Agent", agent.Name); + + // Verify that the custom chat client can be retrieved from the agent's service collection + var retrievedTestClient = agent.GetService(); + Assert.NotNull(retrievedTestClient); + Assert.Same(testChatClient, retrievedTestClient); + } + + /// + /// Verify that GetAIAgent throws ArgumentNullException when assistantClientResult is null. + /// + [Fact] + public void GetAIAgent_WithNullClientResult_ThrowsArgumentNullException() + { + // Arrange + var assistantClient = new TestAssistantClient(); + var options = new ChatClientAgentOptions(); + + // Act & Assert + var exception = Assert.Throws(() => + assistantClient.GetAIAgent((ClientResult)null!, options)); + + Assert.Equal("assistantClientResult", exception.ParamName); + } + + /// + /// Verify that GetAIAgent throws ArgumentNullException when assistant is null. + /// + [Fact] + public void GetAIAgent_WithNullAssistant_ThrowsArgumentNullException() + { + // Arrange + var assistantClient = new TestAssistantClient(); + var options = new ChatClientAgentOptions(); + + // Act & Assert + var exception = Assert.Throws(() => + assistantClient.GetAIAgent((Assistant)null!, options)); + + Assert.Equal("assistantMetadata", exception.ParamName); + } + + /// + /// Verify that GetAIAgent throws ArgumentNullException when options is null. + /// + [Fact] + public void GetAIAgent_WithNullOptions_ThrowsArgumentNullException() + { + // Arrange + var assistantClient = new TestAssistantClient(); + var assistant = ModelReaderWriter.Read(BinaryData.FromString("""{"id": "asst_abc123"}"""))!; + + // Act & Assert + var exception = Assert.Throws(() => + assistantClient.GetAIAgent(assistant, (ChatClientAgentOptions)null!)); + + Assert.Equal("options", exception.ParamName); + } + + /// + /// Verify that GetAIAgent throws ArgumentException when agentId is empty. + /// + [Fact] + public void GetAIAgent_WithEmptyAgentId_ThrowsArgumentException() + { + // Arrange + var assistantClient = new TestAssistantClient(); + var options = new ChatClientAgentOptions(); + + // Act & Assert + var exception = Assert.Throws(() => + assistantClient.GetAIAgent(string.Empty, options)); + + Assert.Equal("agentId", exception.ParamName); + } + + /// + /// Verify that GetAIAgentAsync throws ArgumentException when agentId is empty. + /// + [Fact] + public async Task GetAIAgentAsync_WithEmptyAgentId_ThrowsArgumentExceptionAsync() + { + // Arrange + var assistantClient = new TestAssistantClient(); + var options = new ChatClientAgentOptions(); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + assistantClient.GetAIAgentAsync(string.Empty, options)); + + Assert.Equal("agentId", exception.ParamName); + } + /// /// Creates a test AssistantClient implementation for testing. /// @@ -220,6 +468,17 @@ public sealed class OpenAIAssistantClientExtensionsTests { return ClientResult.FromValue(ModelReaderWriter.Read(BinaryData.FromString("""{"id": "asst_abc123"}""")), new FakePipelineResponse())!; } + + public override ClientResult GetAssistant(string assistantId, CancellationToken cancellationToken = default) + { + return ClientResult.FromValue(ModelReaderWriter.Read(BinaryData.FromString("""{"id": "asst_abc123", "name": "Original Name", "description": "Original Description", "instructions": "Original Instructions"}""")), new FakePipelineResponse())!; + } + + public override async Task> GetAssistantAsync(string assistantId, CancellationToken cancellationToken = default) + { + await Task.Delay(1, cancellationToken); // Simulate async operation + return ClientResult.FromValue(ModelReaderWriter.Read(BinaryData.FromString("""{"id": "asst_abc123", "name": "Original Name", "description": "Original Description", "instructions": "Original Instructions"}""")), new FakePipelineResponse())!; + } } private sealed class TestChatClient : DelegatingChatClient diff --git a/dotnet/tests/OpenAIAssistant.IntegrationTests/OpenAIAssistant.IntegrationTests.csproj b/dotnet/tests/OpenAIAssistant.IntegrationTests/OpenAIAssistant.IntegrationTests.csproj index df161719c9..17ca46e4af 100644 --- a/dotnet/tests/OpenAIAssistant.IntegrationTests/OpenAIAssistant.IntegrationTests.csproj +++ b/dotnet/tests/OpenAIAssistant.IntegrationTests/OpenAIAssistant.IntegrationTests.csproj @@ -1,7 +1,8 @@ - + $(ProjectsTargetFrameworks) + $(ProjectsDebugTargetFrameworks) True $(NoWarn);OPENAI001; @@ -12,6 +13,7 @@ + diff --git a/dotnet/tests/OpenAIAssistant.IntegrationTests/OpenAIAssistantClientExtensionsTests.cs b/dotnet/tests/OpenAIAssistant.IntegrationTests/OpenAIAssistantClientExtensionsTests.cs new file mode 100644 index 0000000000..c5a683ec2d --- /dev/null +++ b/dotnet/tests/OpenAIAssistant.IntegrationTests/OpenAIAssistantClientExtensionsTests.cs @@ -0,0 +1,192 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.IO; +using System.Threading.Tasks; +using AgentConformance.IntegrationTests.Support; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using OpenAI; +using OpenAI.Assistants; +using OpenAI.Files; +using OpenAI.VectorStores; +using Shared.IntegrationTests; + +namespace OpenAIAssistant.IntegrationTests; + +public class OpenAIAssistantClientExtensionsTests +{ + private static readonly OpenAIConfiguration s_config = TestConfiguration.LoadSection(); + private readonly AssistantClient _assistantClient = new OpenAIClient(s_config.ApiKey).GetAssistantClient(); + private readonly OpenAIFileClient _fileClient = new OpenAIClient(s_config.ApiKey).GetOpenAIFileClient(); + + [Theory] + [InlineData("CreateWithChatClientAgentOptionsAsync")] + [InlineData("CreateWithChatClientAgentOptionsSync")] + [InlineData("CreateWithParamsAsync")] + public async Task CreateAIAgentAsync_WithAIFunctionTool_InvokesFunctionAsync(string createMechanism) + { + // Arrange + const string AgentInstructions = "You are a helpful weather assistant. Always call the GetWeather function to answer questions about weather."; + + static string GetWeather(string location) => $"The weather in {location} is sunny with a high of 23C."; + var weatherFunction = AIFunctionFactory.Create(GetWeather, nameof(GetWeather)); + + // Act + var agent = createMechanism switch + { + "CreateWithChatClientAgentOptionsAsync" => await this._assistantClient.CreateAIAgentAsync( + model: s_config.ChatModelId!, + options: new ChatClientAgentOptions( + instructions: AgentInstructions, + tools: [weatherFunction])), + "CreateWithChatClientAgentOptionsSync" => this._assistantClient.CreateAIAgent( + model: s_config.ChatModelId!, + options: new ChatClientAgentOptions( + instructions: AgentInstructions, + tools: [weatherFunction])), + "CreateWithParamsAsync" => await this._assistantClient.CreateAIAgentAsync( + model: s_config.ChatModelId!, + instructions: AgentInstructions, + tools: [weatherFunction]), + _ => throw new InvalidOperationException($"Unknown create mechanism: {createMechanism}") + }; + + try + { + // Trigger function call. + var response = await agent.RunAsync("What is the weather like in Amsterdam?"); + var text = response.Text; + + // Assert + Assert.Contains("Amsterdam", text, StringComparison.OrdinalIgnoreCase); + Assert.Contains("sunny", text, StringComparison.OrdinalIgnoreCase); + Assert.Contains("23", text, StringComparison.OrdinalIgnoreCase); + } + finally + { + await this._assistantClient.DeleteAssistantAsync(agent.Id); + } + } + + [Theory] + [InlineData("CreateWithChatClientAgentOptionsAsync")] + [InlineData("CreateWithChatClientAgentOptionsSync")] + [InlineData("CreateWithParamsAsync")] + public async Task CreateAIAgentAsync_WithHostedCodeInterpreter_RunsCodeAsync(string createMechanism) + { + // Arrange + const string Instructions = "Use the Code Interpreter Tool to run the uploaded python file and respond only with the secret number."; + + // Create a python file that prints a known value. + var codeFilePath = Path.GetTempFileName() + "openai_secret_number.py"; + File.WriteAllText( + path: codeFilePath, + contents: "print(\"OPENAI_SECRET=13579\")" // Deterministic output we will look for. + ); + + // Upload file to OpenAI Assistants file store for use with the Code Interpreter. + var uploadResult = await this._fileClient.UploadFileAsync(codeFilePath, FileUploadPurpose.Assistants); + string uploadedFileId = uploadResult.Value.Id; + var codeInterpreterTool = new HostedCodeInterpreterTool() { Inputs = [new HostedFileContent(uploadedFileId)] }; + + var agent = createMechanism switch + { + "CreateWithChatClientAgentOptionsAsync" => await this._assistantClient.CreateAIAgentAsync( + model: s_config.ChatModelId!, + options: new ChatClientAgentOptions( + instructions: Instructions, + tools: [codeInterpreterTool])), + "CreateWithChatClientAgentOptionsSync" => this._assistantClient.CreateAIAgent( + model: s_config.ChatModelId!, + options: new ChatClientAgentOptions( + instructions: Instructions, + tools: [codeInterpreterTool])), + "CreateWithParamsAsync" => await this._assistantClient.CreateAIAgentAsync( + model: s_config.ChatModelId!, + instructions: Instructions, + tools: [codeInterpreterTool]), + _ => throw new InvalidOperationException($"Unknown create mechanism: {createMechanism}") + }; + + try + { + var response = await agent.RunAsync("What is the OPENAI_SECRET number?"); + var text = response.ToString(); + Assert.Contains("13579", text); + } + finally + { + await this._assistantClient.DeleteAssistantAsync(agent.Id); + await this._fileClient.DeleteFileAsync(uploadedFileId); + File.Delete(codeFilePath); + } + } + + [Theory] + [InlineData("CreateWithChatClientAgentOptionsAsync")] + [InlineData("CreateWithChatClientAgentOptionsSync")] + [InlineData("CreateWithParamsAsync")] + public async Task CreateAIAgentAsync_WithHostedFileSearchTool_SearchesFilesAsync(string createMechanism) + { + // Arrange. + const string Instructions = """ + You are a helpful agent that can help fetch data from files you know about. + Use the File Search Tool to look up codes for words. + Do not answer a question unless you can find the answer using the File Search Tool. + """; + + // Create a local file with deterministic content and upload it. + var searchFilePath = Path.GetTempFileName() + "wordcodelookup.txt"; + File.WriteAllText( + path: searchFilePath, + contents: "The word 'apple' uses the code 442345, while the word 'banana' uses the code 673457."); + var uploadResult = await this._fileClient.UploadFileAsync(searchFilePath, FileUploadPurpose.Assistants); + string uploadedFileId = uploadResult.Value.Id; + + // Create a vector store backing the file search (HostedFileSearchTool requires a vector store id). + var vectorStoreClient = new OpenAIClient(s_config.ApiKey).GetVectorStoreClient(); + var vectorStoreCreate = await vectorStoreClient.CreateVectorStoreAsync(options: new VectorStoreCreationOptions() + { + Name = "WordCodeLookup_VectorStore", + FileIds = { uploadedFileId } + }); + string vectorStoreId = vectorStoreCreate.Value.Id; + + var fileSearchTool = new HostedFileSearchTool() { Inputs = [new HostedVectorStoreContent(vectorStoreId)] }; + + var agent = createMechanism switch + { + "CreateWithChatClientAgentOptionsAsync" => await this._assistantClient.CreateAIAgentAsync( + model: s_config.ChatModelId!, + options: new ChatClientAgentOptions( + instructions: Instructions, + tools: [fileSearchTool])), + "CreateWithChatClientAgentOptionsSync" => this._assistantClient.CreateAIAgent( + model: s_config.ChatModelId!, + options: new ChatClientAgentOptions( + instructions: Instructions, + tools: [fileSearchTool])), + "CreateWithParamsAsync" => await this._assistantClient.CreateAIAgentAsync( + model: s_config.ChatModelId!, + instructions: Instructions, + tools: [fileSearchTool]), + _ => throw new InvalidOperationException($"Unknown create mechanism: {createMechanism}") + }; + + try + { + // Act - ask about banana code which must be retrieved via file search. + var response = await agent.RunAsync("Can you give me the documented code for 'banana'?"); + var text = response.ToString(); + Assert.Contains("673457", text); + } + finally + { + await this._assistantClient.DeleteAssistantAsync(agent.Id); + await vectorStoreClient.DeleteVectorStoreAsync(vectorStoreId); + await this._fileClient.DeleteFileAsync(uploadedFileId); + File.Delete(searchFilePath); + } + } +}