From 105dc82c39e9d20eff931b438c54ff2315fa1df2 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> Date: Mon, 10 Nov 2025 18:34:32 +0000 Subject: [PATCH] .NET: Feature foundry agent + user agent (#2058) * Update unit tests * Add user-agent protocol calls * Update unit tests * Update unit tests with http handler confirmation * UT fix * Fix xmldoc * Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Address copilot feedback --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../AgentsClientExtensions.cs | 206 ++++++++----- .../RequestOptionsExtensions.cs | 67 ++++ .../AgentsClientExtensionsTests.cs | 290 ++++++++++++++++-- 3 files changed, 457 insertions(+), 106 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.AI.AzureAI/RequestOptionsExtensions.cs diff --git a/dotnet/src/Microsoft.Agents.AI.AzureAI/AgentsClientExtensions.cs b/dotnet/src/Microsoft.Agents.AI.AzureAI/AgentsClientExtensions.cs index cbc14d90d1..6261cac4ef 100644 --- a/dotnet/src/Microsoft.Agents.AI.AzureAI/AgentsClientExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.AzureAI/AgentsClientExtensions.cs @@ -1,5 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. +using System.ClientModel; +using System.ClientModel.Primitives; using Microsoft.Agents.AI; using Microsoft.Agents.AI.AzureAI; using Microsoft.Extensions.AI; @@ -20,7 +22,7 @@ public static class AgentClientExtensions /// /// Retrieves an existing server side agent, wrapped as a using the provided . /// - /// The to create the with. Cannot be . + /// The to create the with. Cannot be . /// The name of the server side agent to create a for. Cannot be or whitespace. /// The tools to use when interacting with the agent. This is required when using prompt agent definitions with tools. /// Provides a way to customize the creation of the underlying used by the agent. @@ -28,12 +30,12 @@ public static class AgentClientExtensions /// An optional to use for resolving services required by the instances being invoked. /// The to monitor for cancellation requests. The default is . /// A instance that can be used to perform operations based on the latest version of the named Azure AI Agent. - /// Thrown when or is . + /// Thrown when or is . /// Thrown when is empty or whitespace, or when the agent with the specified name was not found. /// The agent with the specified name was not found. /// When using prompt agent definitions with tools the parameter needs to be provided. public static ChatClientAgent GetAIAgent( - this AgentClient AgentClient, + this AgentClient agentClient, string name, IList? tools = null, Func? clientFactory = null, @@ -41,14 +43,13 @@ public static class AgentClientExtensions IServiceProvider? services = null, CancellationToken cancellationToken = default) { - Throw.IfNull(AgentClient); + Throw.IfNull(agentClient); Throw.IfNullOrWhitespace(name); - var agentRecord = AgentClient.GetAgent(name, cancellationToken).Value - ?? throw new InvalidOperationException($"Agent with name '{name}' not found."); + AgentRecord agentRecord = GetAgentRecordByName(agentClient, name, cancellationToken); return GetAIAgent( - AgentClient, + agentClient, agentRecord, tools, clientFactory, @@ -60,7 +61,7 @@ public static class AgentClientExtensions /// /// Asynchronously retrieves an existing server side agent, wrapped as a using the provided . /// - /// The to create the with. Cannot be . + /// The to create the with. Cannot be . /// The name of the server side agent to create a for. Cannot be or whitespace. /// The tools to use when interacting with the agent. This is required when using prompt agent definitions with tools. /// Provides a way to customize the creation of the underlying used by the agent. @@ -68,12 +69,12 @@ public static class AgentClientExtensions /// An optional to use for resolving services required by the instances being invoked. /// The to monitor for cancellation requests. The default is . /// A instance that can be used to perform operations based on the latest version of the named Azure AI Agent. - /// Thrown when or is . + /// Thrown when or is . /// Thrown when is empty or whitespace, or when the agent with the specified name was not found. /// The agent with the specified name was not found. /// When using prompt agent definitions with tools the parameter needs to be provided. public static async Task GetAIAgentAsync( - this AgentClient AgentClient, + this AgentClient agentClient, string name, IList? tools = null, Func? clientFactory = null, @@ -81,14 +82,13 @@ public static class AgentClientExtensions IServiceProvider? services = null, CancellationToken cancellationToken = default) { - Throw.IfNull(AgentClient); + Throw.IfNull(agentClient); Throw.IfNullOrWhitespace(name); - var agentRecord = (await AgentClient.GetAgentAsync(name, cancellationToken).ConfigureAwait(false)).Value - ?? throw new InvalidOperationException($"Agent with name '{name}' not found."); + AgentRecord agentRecord = await GetAgentRecordByNameAsync(agentClient, name, cancellationToken).ConfigureAwait(false); return GetAIAgent( - AgentClient, + agentClient, agentRecord, tools, clientFactory, @@ -100,7 +100,7 @@ public static class AgentClientExtensions /// /// Gets a runnable agent instance from the provided agent record. /// - /// The client used to interact with Azure AI Agents. Cannot be . + /// The client used to interact with Azure AI Agents. Cannot be . /// The agent record to be converted. The latest version will be used. Cannot be . /// The tools to use when interacting with the agent. This is required when using prompt agent definitions with tools. /// Provides a way to customize the creation of the underlying used by the agent. @@ -110,7 +110,7 @@ public static class AgentClientExtensions /// A instance that can be used to perform operations based on the latest version of the Azure AI Agent. /// When using prompt agent definitions with tools the parameter needs to be provided. public static ChatClientAgent GetAIAgent( - this AgentClient AgentClient, + this AgentClient agentClient, AgentRecord agentRecord, IList? tools = null, Func? clientFactory = null, @@ -118,11 +118,11 @@ public static class AgentClientExtensions IServiceProvider? services = null, CancellationToken cancellationToken = default) { - Throw.IfNull(AgentClient); + Throw.IfNull(agentClient); Throw.IfNull(agentRecord); return GetAIAgent( - AgentClient, + agentClient, agentRecord.Versions.Latest, tools, clientFactory, @@ -134,7 +134,7 @@ public static class AgentClientExtensions /// /// Gets a runnable agent instance from a containing metadata about an Azure AI Agent. /// - /// The client used to interact with Azure AI Agents. Cannot be . + /// The client used to interact with Azure AI Agents. Cannot be . /// The agent version to be converted. Cannot be . /// The tools to use when interacting with the agent. This is required when using prompt agent definitions with tools. /// Provides a way to customize the creation of the underlying used by the agent. @@ -142,10 +142,10 @@ public static class AgentClientExtensions /// An optional to use for resolving services required by the instances being invoked. /// The to monitor for cancellation requests. The default is . /// A instance that can be used to perform operations based on the provided version of the Azure AI Agent. - /// Thrown when or is . + /// Thrown when or is . /// When using prompt agent definitions with tools the parameter needs to be provided. public static ChatClientAgent GetAIAgent( - this AgentClient AgentClient, + this AgentClient agentClient, AgentVersion agentVersion, IList? tools = null, Func? clientFactory = null, @@ -153,13 +153,13 @@ public static class AgentClientExtensions IServiceProvider? services = null, CancellationToken cancellationToken = default) { - Throw.IfNull(AgentClient); + Throw.IfNull(agentClient); Throw.IfNull(agentVersion); ValidateUsingToolsParameter(agentVersion, tools); return CreateChatClientAgent( - AgentClient, + agentClient, agentVersion, tools, clientFactory, @@ -171,23 +171,23 @@ public static class AgentClientExtensions /// /// Creates a new Prompt AI Agent using the provided and options. /// - /// The client used to manage and interact with AI agents. Cannot be . + /// The client used to manage and interact with AI agents. Cannot be . /// The options for creating the agent. Cannot be . /// A factory function to customize the creation of the chat client used by the agent. /// An optional for configuring the underlying OpenAI client. /// An optional to use for resolving services required by the instances being invoked. /// A to cancel the operation if needed. /// A instance that can be used to perform operations on the newly created agent. - /// Thrown when or is . + /// Thrown when or is . public static ChatClientAgent GetAIAgent( - this AgentClient AgentClient, + this AgentClient agentClient, ChatClientAgentOptions options, Func? clientFactory = null, OpenAIClientOptions? openAIClientOptions = null, IServiceProvider? services = null, CancellationToken cancellationToken = default) { - Throw.IfNull(AgentClient); + Throw.IfNull(agentClient); Throw.IfNull(options); if (string.IsNullOrWhiteSpace(options.Name)) @@ -195,15 +195,13 @@ public static class AgentClientExtensions throw new ArgumentException("Agent name must be provided in the options.Name property", nameof(options)); } - var agentRecord = AgentClient.GetAgent(options.Name, cancellationToken).Value - ?? throw new InvalidOperationException($"Agent with name '{options.Name}' not found."); - + AgentRecord agentRecord = GetAgentRecordByName(agentClient, options.Name, cancellationToken); var agentVersion = agentRecord.Versions.Latest; var agentOptions = CreateChatClientAgentOptions(agentVersion, options, requireInvocableTools: true); return CreateChatClientAgent( - AgentClient, + agentClient, agentVersion, agentOptions, clientFactory, @@ -215,23 +213,23 @@ public static class AgentClientExtensions /// /// Creates a new Prompt AI Agent using the provided and options. /// - /// The client used to manage and interact with AI agents. Cannot be . + /// The client used to manage and interact with AI agents. Cannot be . /// The options for creating the agent. Cannot be . /// A factory function to customize the creation of the chat client used by the agent. /// An optional for configuring the underlying OpenAI client. /// An optional to use for resolving services required by the instances being invoked. /// A to cancel the operation if needed. /// A instance that can be used to perform operations on the newly created agent. - /// Thrown when or is . + /// Thrown when or is . public static async Task GetAIAgentAsync( - this AgentClient AgentClient, + this AgentClient agentClient, ChatClientAgentOptions options, Func? clientFactory = null, OpenAIClientOptions? openAIClientOptions = null, IServiceProvider? services = null, CancellationToken cancellationToken = default) { - Throw.IfNull(AgentClient); + Throw.IfNull(agentClient); Throw.IfNull(options); if (string.IsNullOrWhiteSpace(options.Name)) @@ -239,15 +237,13 @@ public static class AgentClientExtensions throw new ArgumentException("Agent name must be provided in the options.Name property", nameof(options)); } - var agentRecord = (await AgentClient.GetAgentAsync(options.Name, cancellationToken).ConfigureAwait(false)).Value - ?? throw new InvalidOperationException($"Agent with name '{options.Name}' not found."); - + AgentRecord agentRecord = await GetAgentRecordByNameAsync(agentClient, options.Name, cancellationToken).ConfigureAwait(false); var agentVersion = agentRecord.Versions.Latest; var agentOptions = CreateChatClientAgentOptions(agentVersion, options, requireInvocableTools: true); return CreateChatClientAgent( - AgentClient, + agentClient, agentVersion, agentOptions, clientFactory, @@ -259,7 +255,7 @@ public static class AgentClientExtensions /// /// Creates a new Prompt AI agent using the specified configuration parameters. /// - /// The client used to manage and interact with AI agents. Cannot be . + /// The client used to manage and interact with AI agents. Cannot be . /// The name for the agent. /// The name of the model to use for the agent. Cannot be or whitespace. /// The instructions that guide the agent's behavior. Cannot be or whitespace. @@ -270,11 +266,11 @@ public static class AgentClientExtensions /// An optional to use for resolving services required by the instances being invoked. /// A token to monitor for cancellation requests. /// A instance that can be used to perform operations on the newly created agent. - /// Thrown when , , or is . + /// Thrown when , , or is . /// Thrown when or is empty or whitespace. /// When using prompt agent definitions with tools the parameter needs to be provided. public static ChatClientAgent CreateAIAgent( - this AgentClient AgentClient, + this AgentClient agentClient, string name, string model, string instructions, @@ -285,13 +281,13 @@ public static class AgentClientExtensions IServiceProvider? services = null, CancellationToken cancellationToken = default) { - Throw.IfNull(AgentClient); + Throw.IfNull(agentClient); Throw.IfNullOrWhitespace(name); Throw.IfNullOrWhitespace(model); Throw.IfNullOrWhitespace(instructions); return CreateAIAgent( - AgentClient, + agentClient, name, tools, new AgentVersionCreationOptions(new PromptAgentDefinition(model) { Instructions = instructions }) { Description = description }, @@ -305,7 +301,7 @@ public static class AgentClientExtensions /// /// Creates a new Prompt AI agent using the specified configuration parameters. /// - /// The client used to manage and interact with AI agents. Cannot be . + /// The client used to manage and interact with AI agents. Cannot be . /// The name for the agent. /// The name of the model to use for the agent. Cannot be or whitespace. /// The instructions that guide the agent's behavior. Cannot be or whitespace. @@ -316,11 +312,11 @@ public static class AgentClientExtensions /// An optional to use for resolving services required by the instances being invoked. /// A token to monitor for cancellation requests. /// A instance that can be used to perform operations on the newly created agent. - /// Thrown when , , or is . + /// Thrown when , , or is . /// Thrown when or is empty or whitespace. /// When using prompt agent definitions with tools the parameter needs to be provided. public static Task CreateAIAgentAsync( - this AgentClient AgentClient, + this AgentClient agentClient, string name, string model, string instructions, @@ -331,13 +327,13 @@ public static class AgentClientExtensions IServiceProvider? services = null, CancellationToken cancellationToken = default) { - Throw.IfNull(AgentClient); + Throw.IfNull(agentClient); Throw.IfNullOrWhitespace(name); Throw.IfNullOrWhitespace(model); Throw.IfNullOrWhitespace(instructions); return CreateAIAgentAsync( - AgentClient, + agentClient, name, tools, new AgentVersionCreationOptions(new PromptAgentDefinition(model) { Instructions = instructions }) { Description = description }, @@ -351,7 +347,7 @@ public static class AgentClientExtensions /// /// Creates a new Prompt AI Agent using the provided and options. /// - /// The client used to manage and interact with AI agents. Cannot be . + /// The client used to manage and interact with AI agents. Cannot be . /// The name of the model to use for the agent. Cannot be or whitespace. /// The options for creating the agent. Cannot be . /// A factory function to customize the creation of the chat client used by the agent. @@ -359,10 +355,10 @@ public static class AgentClientExtensions /// An optional to use for resolving services required by the instances being invoked. /// A to cancel the operation if needed. /// A instance that can be used to perform operations on the newly created agent. - /// Thrown when or is . + /// Thrown when or is . /// Thrown when is empty or whitespace, or when the agent name is not provided in the options. public static ChatClientAgent CreateAIAgent( - this AgentClient AgentClient, + this AgentClient agentClient, string model, ChatClientAgentOptions options, Func? clientFactory = null, @@ -370,7 +366,7 @@ public static class AgentClientExtensions IServiceProvider? services = null, CancellationToken cancellationToken = default) { - Throw.IfNull(AgentClient); + Throw.IfNull(agentClient); Throw.IfNull(options); Throw.IfNullOrWhitespace(model); const bool RequireInvocableTools = true; @@ -387,18 +383,18 @@ public static class AgentClientExtensions ApplyToolsToAgentDefinition(agentDefinition, options.ChatOptions?.Tools, RequireInvocableTools); - AgentVersionCreationOptions? versionCreationOptions = new(agentDefinition); + AgentVersionCreationOptions? creationOptions = new(agentDefinition); if (!string.IsNullOrWhiteSpace(options.Description)) { - versionCreationOptions.Description = options.Description; + creationOptions.Description = options.Description; } - AgentVersion agentVersion = AgentClient.CreateAgentVersion(options.Name, versionCreationOptions, cancellationToken).Value; + AgentVersion agentVersion = CreateAgentVersionWithProtocol(agentClient, options.Name, creationOptions, cancellationToken); var agentOptions = CreateChatClientAgentOptions(agentVersion, options, RequireInvocableTools); return CreateChatClientAgent( - AgentClient, + agentClient, agentVersion, agentOptions, clientFactory, @@ -410,7 +406,7 @@ public static class AgentClientExtensions /// /// Creates a new Prompt AI Agent using the provided and options. /// - /// The client used to manage and interact with AI agents. Cannot be . + /// The client used to manage and interact with AI agents. Cannot be . /// The name of the model to use for the agent. Cannot be or whitespace. /// The options for creating the agent. Cannot be . /// A factory function to customize the creation of the chat client used by the agent. @@ -418,10 +414,10 @@ public static class AgentClientExtensions /// An optional to use for resolving services required by the instances being invoked. /// A to cancel the operation if needed. /// A instance that can be used to perform operations on the newly created agent. - /// Thrown when or is . + /// Thrown when or is . /// Thrown when is empty or whitespace, or when the agent name is not provided in the options. public static async Task CreateAIAgentAsync( - this AgentClient AgentClient, + this AgentClient agentClient, string model, ChatClientAgentOptions options, Func? clientFactory = null, @@ -429,7 +425,7 @@ public static class AgentClientExtensions IServiceProvider? services = null, CancellationToken cancellationToken = default) { - Throw.IfNull(AgentClient); + Throw.IfNull(agentClient); Throw.IfNull(options); Throw.IfNullOrWhitespace(model); const bool RequireInvocableTools = true; @@ -446,18 +442,18 @@ public static class AgentClientExtensions ApplyToolsToAgentDefinition(agentDefinition, options.ChatOptions?.Tools, RequireInvocableTools); - AgentVersionCreationOptions? versionCreationOptions = new(agentDefinition); + AgentVersionCreationOptions? creationOptions = new(agentDefinition); if (!string.IsNullOrWhiteSpace(options.Description)) { - versionCreationOptions.Description = options.Description; + creationOptions.Description = options.Description; } - AgentVersion agentVersion = await AgentClient.CreateAgentVersionAsync(options.Name, versionCreationOptions, cancellationToken).ConfigureAwait(false); + AgentVersion agentVersion = await CreateAgentVersionWithProtocolAsync(agentClient, options.Name, creationOptions, cancellationToken).ConfigureAwait(false); var agentOptions = CreateChatClientAgentOptions(agentVersion, options, RequireInvocableTools); return CreateChatClientAgent( - AgentClient, + agentClient, agentVersion, agentOptions, clientFactory, @@ -469,34 +465,34 @@ public static class AgentClientExtensions /// /// Creates a new AI agent using the specified agent definition and optional configuration parameters. /// - /// The client used to manage and interact with AI agents. Cannot be . + /// The client used to manage and interact with AI agents. Cannot be . /// The name for the agent. /// Settings that control the creation of the agent. /// A factory function to customize the creation of the chat client used by the agent. /// An optional for configuring the underlying OpenAI client. /// A token to monitor for cancellation requests. /// A instance that can be used to perform operations on the newly created agent. - /// Thrown when or is . + /// Thrown when or is . /// /// When using this extension method with a the tools are only declarative and not invocable. /// Invocation of any in-process tools will need to be handled manually. /// public static ChatClientAgent CreateAIAgent( - this AgentClient AgentClient, + this AgentClient agentClient, string name, AgentVersionCreationOptions creationOptions, Func? clientFactory = null, OpenAIClientOptions? openAIClientOptions = null, CancellationToken cancellationToken = default) { - Throw.IfNull(AgentClient); + Throw.IfNull(agentClient); Throw.IfNullOrWhitespace(name); Throw.IfNull(creationOptions); var tools = (creationOptions.Definition as PromptAgentDefinition)?.Tools.Select(t => t.AsAITool()).ToList(); return CreateAIAgent( - AgentClient, + agentClient, name, tools, creationOptions, @@ -511,20 +507,20 @@ public static class AgentClientExtensions /// Asynchronously creates a new AI agent using the specified agent definition and optional configuration /// parameters. /// - /// The client used to manage and interact with AI agents. Cannot be . + /// The client used to manage and interact with AI agents. Cannot be . /// The name for the agent. /// Settings that control the creation of the agent. /// A factory function to customize the creation of the chat client used by the agent. /// An optional for configuring the underlying OpenAI client. /// A token to monitor for cancellation requests. /// A instance that can be used to perform operations on the newly created agent. - /// Thrown when or is . + /// Thrown when or is . /// /// When using this extension method with a the tools are only declarative and not invocable. /// Invocation of any in-process tools will need to be handled manually. /// public static Task CreateAIAgentAsync( - this AgentClient AgentClient, + this AgentClient agentClient, string name, AgentVersionCreationOptions creationOptions, Func? clientFactory = null, @@ -532,13 +528,13 @@ public static class AgentClientExtensions CancellationToken cancellationToken = default) { Throw.IfNullOrWhitespace(name); - Throw.IfNull(AgentClient); + Throw.IfNull(agentClient); Throw.IfNull(creationOptions); var tools = (creationOptions.Definition as PromptAgentDefinition)?.Tools.Select(t => t.AsAITool()).ToList(); return CreateAIAgentAsync( - AgentClient, + agentClient, name, tools, creationOptions, @@ -551,8 +547,48 @@ public static class AgentClientExtensions #region Private + /// + /// Retrieves an agent record by name using the Protocol method with user-agent header. + /// + private static AgentRecord GetAgentRecordByName(AgentClient agentClient, string agentName, CancellationToken cancellationToken) + { + ClientResult protocolResponse = agentClient.GetAgent(agentName, cancellationToken.ToRequestOptions(false)); + return ClientResult.FromOptionalValue((AgentRecord)protocolResponse, protocolResponse.GetRawResponse()).Value + ?? throw new InvalidOperationException($"Agent with name '{agentName}' not found."); + } + + /// + /// Asynchronously retrieves an agent record by name using the Protocol method with user-agent header. + /// + private static async Task GetAgentRecordByNameAsync(AgentClient agentClient, string agentName, CancellationToken cancellationToken) + { + ClientResult protocolResponse = await agentClient.GetAgentAsync(agentName, cancellationToken.ToRequestOptions(false)).ConfigureAwait(false); + return ClientResult.FromOptionalValue((AgentRecord)protocolResponse, protocolResponse.GetRawResponse()).Value + ?? throw new InvalidOperationException($"Agent with name '{agentName}' not found."); + } + + /// + /// Creates an agent version using the Protocol method with user-agent header. + /// + private static AgentVersion CreateAgentVersionWithProtocol(AgentClient agentClient, string agentName, AgentVersionCreationOptions creationOptions, CancellationToken cancellationToken) + { + using BinaryContent protocolRequest = BinaryContent.Create(ModelReaderWriter.Write(creationOptions)); + ClientResult protocolResponse = agentClient.CreateAgentVersion(agentName, protocolRequest, cancellationToken.ToRequestOptions(false)); + return ClientResult.FromValue((AgentVersion)protocolResponse, protocolResponse.GetRawResponse()).Value; + } + + /// + /// Asynchronously creates an agent version using the Protocol method with user-agent header. + /// + private static async Task CreateAgentVersionWithProtocolAsync(AgentClient agentClient, string agentName, AgentVersionCreationOptions creationOptions, CancellationToken cancellationToken) + { + using BinaryContent protocolRequest = BinaryContent.Create(ModelReaderWriter.Write(creationOptions)); + ClientResult protocolResponse = await agentClient.CreateAgentVersionAsync(agentName, protocolRequest, cancellationToken.ToRequestOptions(false)).ConfigureAwait(false); + return ClientResult.FromValue((AgentVersion)protocolResponse, protocolResponse.GetRawResponse()).Value; + } + private static ChatClientAgent CreateAIAgent( - this AgentClient AgentClient, + this AgentClient agentClient, string name, IList? tools, AgentVersionCreationOptions creationOptions, @@ -562,7 +598,7 @@ public static class AgentClientExtensions IServiceProvider? services, CancellationToken cancellationToken) { - Throw.IfNull(AgentClient); + Throw.IfNull(agentClient); Throw.IfNullOrWhitespace(name); Throw.IfNull(creationOptions); @@ -570,10 +606,10 @@ public static class AgentClientExtensions ApplyToolsToAgentDefinition(creationOptions.Definition, tools, requireInvocableTools); - AgentVersion agentVersion = AgentClient.CreateAgentVersion(name, creationOptions, cancellationToken).Value; + AgentVersion agentVersion = CreateAgentVersionWithProtocol(agentClient, name, creationOptions, cancellationToken); return CreateChatClientAgent( - AgentClient, + agentClient, agentVersion, tools, clientFactory, @@ -583,7 +619,7 @@ public static class AgentClientExtensions } private static async Task CreateAIAgentAsync( - this AgentClient AgentClient, + this AgentClient agentClient, string name, IList? tools, AgentVersionCreationOptions creationOptions, @@ -594,17 +630,17 @@ public static class AgentClientExtensions CancellationToken cancellationToken) { Throw.IfNullOrWhitespace(name); - Throw.IfNull(AgentClient); + Throw.IfNull(agentClient); Throw.IfNull(creationOptions); tools ??= (creationOptions.Definition as PromptAgentDefinition)?.Tools.Select(t => t.AsAITool()).ToList(); ApplyToolsToAgentDefinition(creationOptions.Definition, tools, requireInvocableTools); - AgentVersion agentVersion = await AgentClient.CreateAgentVersionAsync(name, creationOptions, cancellationToken).ConfigureAwait(false); + AgentVersion agentVersion = await CreateAgentVersionWithProtocolAsync(agentClient, name, creationOptions, cancellationToken).ConfigureAwait(false); return CreateChatClientAgent( - AgentClient, + agentClient, agentVersion, tools, clientFactory, @@ -615,7 +651,7 @@ public static class AgentClientExtensions /// This method creates an with the specified ChatClientAgentOptions. private static ChatClientAgent CreateChatClientAgent( - AgentClient AgentClient, + AgentClient agentClient, AgentVersion agentVersion, ChatClientAgentOptions agentOptions, Func? clientFactory, @@ -623,7 +659,7 @@ public static class AgentClientExtensions bool requireInvocableTools, IServiceProvider? services) { - IChatClient chatClient = new AzureAIAgentChatClient(AgentClient, agentVersion, agentOptions.ChatOptions, openAIClientOptions); + IChatClient chatClient = new AzureAIAgentChatClient(agentClient, agentVersion, agentOptions.ChatOptions, openAIClientOptions); if (clientFactory is not null) { diff --git a/dotnet/src/Microsoft.Agents.AI.AzureAI/RequestOptionsExtensions.cs b/dotnet/src/Microsoft.Agents.AI.AzureAI/RequestOptionsExtensions.cs new file mode 100644 index 0000000000..722d316330 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.AzureAI/RequestOptionsExtensions.cs @@ -0,0 +1,67 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.ClientModel.Primitives; +using System.Reflection; + +namespace Microsoft.Agents.AI; + +internal static class RequestOptionsExtensions +{ + /// Creates a configured for use with Foundry Agents. + public static RequestOptions ToRequestOptions(this CancellationToken cancellationToken, bool streaming) + { + RequestOptions requestOptions = new() + { + CancellationToken = cancellationToken, + BufferResponse = !streaming + }; + + requestOptions.AddPolicy(MeaiUserAgentPolicy.Instance, PipelinePosition.PerCall); + + return requestOptions; + } + + /// Provides a pipeline policy that adds a "MEAI/x.y.z" user-agent header. + private sealed class MeaiUserAgentPolicy : PipelinePolicy + { + public static MeaiUserAgentPolicy Instance { get; } = new MeaiUserAgentPolicy(); + + private static readonly string s_userAgentValue = CreateUserAgentValue(); + + public override void Process(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) + { + AddUserAgentHeader(message); + ProcessNext(message, pipeline, currentIndex); + } + + public override ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) + { + AddUserAgentHeader(message); + return ProcessNextAsync(message, pipeline, currentIndex); + } + + private static void AddUserAgentHeader(PipelineMessage message) => + message.Request.Headers.Add("User-Agent", s_userAgentValue); + + private static string CreateUserAgentValue() + { + const string Name = "MEAI"; + + if (typeof(MeaiUserAgentPolicy).Assembly.GetCustomAttribute()?.InformationalVersion is string version) + { + int pos = version.IndexOf('+'); + if (pos >= 0) + { + version = version.Substring(0, pos); + } + + if (version.Length > 0) + { + return $"{Name}/{version}"; + } + } + + return Name; + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/AgentsClientExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/AgentsClientExtensionsTests.cs index df2fba3f1c..e3afef7d8a 100644 --- a/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/AgentsClientExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/AgentsClientExtensionsTests.cs @@ -6,6 +6,9 @@ using System.ClientModel.Primitives; using System.Collections.Generic; using System.IO; using System.Linq; +using System.Net; +using System.Net.Http; +using System.Text; using System.Text.Json; using System.Threading; using System.Threading.Tasks; @@ -38,7 +41,7 @@ public sealed class AgentClientExtensionsTests var exception = Assert.Throws(() => client!.GetAIAgent(agentRecord)); - Assert.Equal("AgentClient", exception.ParamName); + Assert.Equal("agentClient", exception.ParamName); } /// @@ -116,7 +119,7 @@ public sealed class AgentClientExtensionsTests var exception = Assert.Throws(() => client!.GetAIAgent(agentVersion)); - Assert.Equal("AgentClient", exception.ParamName); + Assert.Equal("agentClient", exception.ParamName); } /// @@ -234,7 +237,7 @@ public sealed class AgentClientExtensionsTests var exception = Assert.Throws(() => client!.GetAIAgent(options)); - Assert.Equal("AgentClient", exception.ParamName); + Assert.Equal("agentClient", exception.ParamName); } /// @@ -329,7 +332,7 @@ public sealed class AgentClientExtensionsTests var exception = await Assert.ThrowsAsync(() => client!.GetAIAgentAsync(options)); - Assert.Equal("AgentClient", exception.ParamName); + Assert.Equal("agentClient", exception.ParamName); } /// @@ -383,7 +386,7 @@ public sealed class AgentClientExtensionsTests var exception = Assert.Throws(() => client!.GetAIAgent("test-agent")); - Assert.Equal("AgentClient", exception.ParamName); + Assert.Equal("agentClient", exception.ParamName); } /// @@ -426,8 +429,11 @@ public sealed class AgentClientExtensionsTests { // Arrange var mockClient = new Mock(); - mockClient.Setup(c => c.GetAgent(It.IsAny(), It.IsAny())) - .Returns(ClientResult.FromOptionalValue((AgentRecord)null!, new MockPipelineResponse(200))); + mockClient.Setup(c => c.GetAgent(It.IsAny(), It.IsAny())) + .Returns(ClientResult.FromOptionalValue((AgentRecord)null!, new MockPipelineResponse(200, BinaryData.FromString("null")))); + + mockClient.Setup(x => x.GetOpenAIClient(It.IsAny())) + .Returns(new OpenAIClient(new ApiKeyCredential("test-key"))); // Act & Assert var exception = Assert.Throws(() => @@ -453,7 +459,7 @@ public sealed class AgentClientExtensionsTests var exception = await Assert.ThrowsAsync(() => client!.GetAIAgentAsync("test-agent")); - Assert.Equal("AgentClient", exception.ParamName); + Assert.Equal("agentClient", exception.ParamName); } /// @@ -480,8 +486,11 @@ public sealed class AgentClientExtensionsTests { // Arrange var mockClient = new Mock(); - mockClient.Setup(c => c.GetAgentAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(ClientResult.FromOptionalValue((AgentRecord)null!, new MockPipelineResponse(200))); + mockClient.Setup(c => c.GetAgentAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(ClientResult.FromOptionalValue((AgentRecord)null!, new MockPipelineResponse(200, BinaryData.FromString("null")))); + + mockClient.Setup(x => x.GetOpenAIClient(It.IsAny())) + .Returns(new OpenAIClient(new ApiKeyCredential("test-key"))); // Act & Assert var exception = await Assert.ThrowsAsync(() => @@ -580,7 +589,7 @@ public sealed class AgentClientExtensionsTests var exception = Assert.Throws(() => client!.CreateAIAgent("test-agent", "model", "instructions")); - Assert.Equal("AgentClient", exception.ParamName); + Assert.Equal("agentClient", exception.ParamName); } /// @@ -618,7 +627,7 @@ public sealed class AgentClientExtensionsTests var exception = Assert.Throws(() => client!.CreateAIAgent("test-agent", options)); - Assert.Equal("AgentClient", exception.ParamName); + Assert.Equal("agentClient", exception.ParamName); } /// @@ -673,7 +682,7 @@ public sealed class AgentClientExtensionsTests var exception = Assert.Throws(() => client!.CreateAIAgent("model", options)); - Assert.Equal("AgentClient", exception.ParamName); + Assert.Equal("agentClient", exception.ParamName); } /// @@ -847,7 +856,7 @@ public sealed class AgentClientExtensionsTests var exception = await Assert.ThrowsAsync(() => client!.CreateAIAgentAsync("agent-name", options)); - Assert.Equal("AgentClient", exception.ParamName); + Assert.Equal("agentClient", exception.ParamName); } /// @@ -1672,6 +1681,176 @@ public sealed class AgentClientExtensionsTests #endregion + #region User-Agent Header Tests + + /// + /// Verify that GetAIAgent(string name) passes RequestOptions to the Protocol method. + /// + [Fact] + public void GetAIAgent_WithStringName_PassesRequestOptionsToProtocol() + { + // Arrange + RequestOptions? capturedRequestOptions = null; + var mockAgentClient = new Mock(new Uri("https://test.openai.azure.com/"), new FakeAuthenticationTokenProvider()); + mockAgentClient + .Setup(x => x.GetAgent(It.IsAny(), It.IsAny())) + .Callback((name, options) => capturedRequestOptions = options) + .Returns(ClientResult.FromResponse(new MockPipelineResponse(200, BinaryData.FromString(AgentTestJsonObject)))); + + mockAgentClient.Setup(x => x.GetOpenAIClient(It.IsAny())) + .Returns(new OpenAIClient(new ApiKeyCredential("test-key"))); + + // Act + var agent = mockAgentClient.Object.GetAIAgent("test-agent"); + + // Assert + Assert.NotNull(agent); + Assert.NotNull(capturedRequestOptions); + } + + /// + /// Verify that GetAIAgentAsync(string name) passes RequestOptions to the Protocol method. + /// + [Fact] + public async Task GetAIAgentAsync_WithStringName_PassesRequestOptionsToProtocolAsync() + { + // Arrange + RequestOptions? capturedRequestOptions = null; + var mockAgentClient = new Mock(new Uri("https://test.openai.azure.com/"), new FakeAuthenticationTokenProvider()); + mockAgentClient + .Setup(x => x.GetAgentAsync(It.IsAny(), It.IsAny())) + .Callback((name, options) => capturedRequestOptions = options) + .Returns(Task.FromResult(ClientResult.FromResponse(new MockPipelineResponse(200, BinaryData.FromString(AgentTestJsonObject))))); + + mockAgentClient.Setup(x => x.GetOpenAIClient(It.IsAny())) + .Returns(new OpenAIClient(new ApiKeyCredential("test-key"))); + + // Act + var agent = await mockAgentClient.Object.GetAIAgentAsync("test-agent"); + + // Assert + Assert.NotNull(agent); + Assert.NotNull(capturedRequestOptions); + } + + /// + /// Verify that CreateAIAgent(string model, ChatClientAgentOptions options) passes RequestOptions to the Protocol method. + /// + [Fact] + public void CreateAIAgent_WithChatClientAgentOptions_PassesRequestOptionsToProtocol() + { + // Arrange + RequestOptions? capturedRequestOptions = null; + var mockAgentClient = new Mock(new Uri("https://test.openai.azure.com/"), new FakeAuthenticationTokenProvider()); + mockAgentClient + .Setup(x => x.CreateAgentVersion(It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((name, content, options) => capturedRequestOptions = options) + .Returns(ClientResult.FromResponse(new MockPipelineResponse(200, BinaryData.FromString(AgentVersionTestJsonObject)))); + + mockAgentClient.Setup(x => x.GetOpenAIClient(It.IsAny())) + .Returns(new OpenAIClient(new ApiKeyCredential("test-key"))); + + var agentOptions = new ChatClientAgentOptions { Name = "test-agent" }; + + // Act + var agent = mockAgentClient.Object.CreateAIAgent("gpt-4", agentOptions); + + // Assert + Assert.NotNull(agent); + Assert.NotNull(capturedRequestOptions); + } + + /// + /// Verify that CreateAIAgentAsync(string model, ChatClientAgentOptions options) passes RequestOptions to the Protocol method. + /// + [Fact] + public async Task CreateAIAgentAsync_WithChatClientAgentOptions_PassesRequestOptionsToProtocolAsync() + { + // Arrange + RequestOptions? capturedRequestOptions = null; + var mockAgentClient = new Mock(new Uri("https://test.openai.azure.com/"), new FakeAuthenticationTokenProvider()); + mockAgentClient + .Setup(x => x.CreateAgentVersionAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((name, content, options) => capturedRequestOptions = options) + .Returns(Task.FromResult(ClientResult.FromResponse(new MockPipelineResponse(200, BinaryData.FromString(AgentVersionTestJsonObject))))); + + mockAgentClient.Setup(x => x.GetOpenAIClient(It.IsAny())) + .Returns(new OpenAIClient(new ApiKeyCredential("test-key"))); + + var agentOptions = new ChatClientAgentOptions { Name = "test-agent" }; + + // Act + var agent = await mockAgentClient.Object.CreateAIAgentAsync("gpt-4", agentOptions); + + // Assert + Assert.NotNull(agent); + Assert.NotNull(capturedRequestOptions); + } + + /// + /// Verifies that the user-agent header is added to both synchronous and asynchronous requests made by agent creation methods. + /// + [Fact] + public async Task CreateAIAgent_UserAgentHeaderAddedToRequestsAsync() + { + using var httpHandler = new HttpHandlerAssert(request => + { + Assert.Equal("POST", request.Method.Method); + Assert.Contains("MEAI", request.Headers.UserAgent.ToString()); + + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(AgentTestJsonObject, Encoding.UTF8, "application/json") }; + }); + +#pragma warning disable CA5399 + using var httpClient = new HttpClient(httpHandler); +#pragma warning restore CA5399 + + // Arrange + var agentClient = new AgentClient(new Uri("https://test.openai.azure.com/"), new FakeAuthenticationTokenProvider(), new() { Transport = new HttpClientPipelineTransport(httpClient) }); + + var agentOptions = new ChatClientAgentOptions { Name = "test-agent" }; + + // Act + var agent1 = agentClient.CreateAIAgent("test", agentOptions); + var agent2 = await agentClient.CreateAIAgentAsync("test", agentOptions); + + // Assert + Assert.NotNull(agent1); + Assert.NotNull(agent2); + } + + /// + /// Verifies that the user-agent header is added to both synchronous and asynchronous GetAIAgent requests. + /// + [Fact] + public async Task GetAIAgent_UserAgentHeaderAddedToRequestsAsync() + { + using var httpHandler = new HttpHandlerAssert(request => + { + Assert.Equal("GET", request.Method.Method); + Assert.Contains("MEAI", request.Headers.UserAgent.ToString()); + + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(AgentTestJsonObject, Encoding.UTF8, "application/json") }; + }); + +#pragma warning disable CA5399 + using var httpClient = new HttpClient(httpHandler); +#pragma warning restore CA5399 + + // Arrange + var agentClient = new AgentClient(new Uri("https://test.openai.azure.com/"), new FakeAuthenticationTokenProvider(), new() { Transport = new HttpClientPipelineTransport(httpClient) }); + + // Act + var agent1 = agentClient.GetAIAgent("test"); + var agent2 = await agentClient.GetAIAgentAsync("test"); + + // Assert + Assert.NotNull(agent1); + Assert.NotNull(agent2); + } + + #endregion + #region Helper Methods /// @@ -1792,21 +1971,41 @@ public sealed class AgentClientExtensionsTests return new OpenAIClient(new ApiKeyCredential("test-key"), options); } + public override ClientResult GetAgent(string agentName, RequestOptions options) + { + return ClientResult.FromValue(ModelReaderWriter.Read(BinaryData.FromString(this.ApplyResponseChanges(AgentTestJsonObject)))!, new MockPipelineResponse(200, BinaryData.FromString(this.ApplyResponseChanges(AgentTestJsonObject)))); + } + public override ClientResult GetAgent(string agentName, CancellationToken cancellationToken = default) { return ClientResult.FromValue(ModelReaderWriter.Read(BinaryData.FromString(this.ApplyResponseChanges(AgentTestJsonObject)))!, new MockPipelineResponse(200)); } + public override Task GetAgentAsync(string agentName, RequestOptions options) + { + return Task.FromResult(ClientResult.FromValue(ModelReaderWriter.Read(BinaryData.FromString(this.ApplyResponseChanges(AgentTestJsonObject)))!, new MockPipelineResponse(200, BinaryData.FromString(this.ApplyResponseChanges(AgentTestJsonObject))))); + } + public override Task> GetAgentAsync(string agentName, CancellationToken cancellationToken = default) { return Task.FromResult(ClientResult.FromValue(ModelReaderWriter.Read(BinaryData.FromString(this.ApplyResponseChanges(AgentTestJsonObject)))!, new MockPipelineResponse(200))); } + public override ClientResult CreateAgentVersion(string agentName, BinaryContent content, RequestOptions? options = null) + { + return ClientResult.FromValue(ModelReaderWriter.Read(BinaryData.FromString(this.ApplyResponseChanges(AgentVersionTestJsonObject)))!, new MockPipelineResponse(200, BinaryData.FromString(this.ApplyResponseChanges(AgentVersionTestJsonObject)))); + } + public override ClientResult CreateAgentVersion(string agentName, AgentVersionCreationOptions? options = null, CancellationToken cancellationToken = default) { return ClientResult.FromValue(ModelReaderWriter.Read(BinaryData.FromString(this.ApplyResponseChanges(AgentVersionTestJsonObject)))!, new MockPipelineResponse(200)); } + public override Task CreateAgentVersionAsync(string agentName, BinaryContent content, RequestOptions? options = null) + { + return Task.FromResult(ClientResult.FromValue(ModelReaderWriter.Read(BinaryData.FromString(this.ApplyResponseChanges(AgentVersionTestJsonObject)))!, new MockPipelineResponse(200, BinaryData.FromString(this.ApplyResponseChanges(AgentVersionTestJsonObject))))); + } + public override Task> CreateAgentVersionAsync(string agentName, AgentVersionCreationOptions? options = null, CancellationToken cancellationToken = default) { return Task.FromResult(ClientResult.FromValue(ModelReaderWriter.Read(BinaryData.FromString(this.ApplyResponseChanges(AgentVersionTestJsonObject)))!, new MockPipelineResponse(200))); @@ -1889,10 +2088,14 @@ public sealed class AgentClientExtensionsTests private sealed class MockPipelineResponse : PipelineResponse { private readonly int _status; + private readonly BinaryData _content; + private readonly MockPipelineResponseHeaders _headers; - public MockPipelineResponse(int status) + public MockPipelineResponse(int status, BinaryData? content = null) { this._status = status; + this._content = content ?? BinaryData.Empty; + this._headers = new MockPipelineResponseHeaders(); } public override int Status => this._status; @@ -1905,9 +2108,9 @@ public sealed class AgentClientExtensionsTests set { } } - public override BinaryData Content => BinaryData.Empty; + public override BinaryData Content => this._content; - protected override PipelineResponseHeaders HeadersCore => new EmptyPipelineResponseHeaders(); + protected override PipelineResponseHeaders HeadersCore => this._headers; public override BinaryData BufferContent(CancellationToken cancellationToken = default) => throw new NotSupportedException("Buffering content is not supported for mock responses."); @@ -1919,26 +2122,71 @@ public sealed class AgentClientExtensionsTests { } - private sealed class EmptyPipelineResponseHeaders : PipelineResponseHeaders + private sealed class MockPipelineResponseHeaders : PipelineResponseHeaders { + private readonly Dictionary _headers = new(StringComparer.OrdinalIgnoreCase) + { + { "Content-Type", "application/json" }, + { "x-ms-request-id", "test-request-id" } + }; + public override bool TryGetValue(string name, out string? value) { - value = null; - return false; + return this._headers.TryGetValue(name, out value); } public override bool TryGetValues(string name, out IEnumerable? values) { + if (this._headers.TryGetValue(name, out var value)) + { + values = new[] { value }; + return true; + } + values = null; return false; } public override IEnumerator> GetEnumerator() { - yield break; + return this._headers.GetEnumerator(); } } } + private sealed class FakeAuthenticationTokenProvider : AuthenticationTokenProvider + { + public override GetTokenOptions? CreateTokenOptions(IReadOnlyDictionary properties) + { + return new GetTokenOptions(new Dictionary()); + } + + public override AuthenticationToken GetToken(GetTokenOptions options, CancellationToken cancellationToken) + { + return new AuthenticationToken("token-value", "token-type", DateTimeOffset.UtcNow.AddHours(1)); + } + + public override ValueTask GetTokenAsync(GetTokenOptions options, CancellationToken cancellationToken) + { + return new ValueTask(this.GetToken(options, cancellationToken)); + } + } + #endregion + + private sealed class HttpHandlerAssert(Func assertion) : HttpClientHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var response = assertion(request); + return Task.FromResult(response); + } + +#if NET + protected override HttpResponseMessage Send(HttpRequestMessage request, CancellationToken cancellationToken) + { + return assertion(request); + } +#endif + } }