From 9027b5c29a2d176d4d47b37e3ff3ba50d9c1a505 Mon Sep 17 00:00:00 2001 From: alliscode Date: Thu, 23 Apr 2026 12:24:28 -0700 Subject: [PATCH 1/7] dotnet: Add hosted-agent User-Agent supplement to outgoing requests When an agent runs inside a Foundry Hosted Agent, the outgoing User-Agent header now includes 'agent-framework-hosted/{version}' alongside the existing 'MEAI/{version}' segment. - Add HostedAgentContext with AsyncLocal property - MeaiUserAgentPolicy reads the supplement per-call - AgentFrameworkResponseHandler sets/restores the context Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Program.cs | 2 +- .../AgentFrameworkResponseHandler.cs | 37 +++++++++++++++- .../HostedAgentContext.cs | 42 +++++++++++++++++++ .../RequestOptionsExtensions.cs | 8 +++- 4 files changed, 84 insertions(+), 5 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.AI.Foundry/HostedAgentContext.cs diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step25_ToolboxServerSideTools/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step25_ToolboxServerSideTools/Program.cs index 12731f4723..354c61441e 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step25_ToolboxServerSideTools/Program.cs +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step25_ToolboxServerSideTools/Program.cs @@ -23,7 +23,7 @@ // Replace with any question that exercises the tools configured in your toolbox. const string Query = "Introduce yourself and briefly describe the tools you can use to help me."; -string endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") +string endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT_2") ?? throw new InvalidOperationException("Set FOUNDRY_PROJECT_ENDPOINT to your Foundry project endpoint."); string model = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini"; diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs index 1c5a57eb49..75976a4927 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using System.Reflection; using System.Runtime.CompilerServices; using System.Threading; using Azure.AI.AgentServer.Responses; @@ -22,6 +23,8 @@ namespace Microsoft.Agents.AI.Foundry.Hosting; [Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)] public class AgentFrameworkResponseHandler : ResponseHandler { + private static readonly string HostedUserAgentValue = CreateHostedUserAgentValue(); + private readonly IServiceProvider _serviceProvider; private readonly ILogger _logger; private readonly FoundryToolboxService? _toolboxService; @@ -176,7 +179,12 @@ public override async IAsyncEnumerable CreateAsync( var options = new ChatClientAgentRunOptions(chatOptions); - // 6. Set up consent context for -32006 OAuth consent interception. + // 6. Set the hosted-agent User-Agent supplement so outgoing API calls include + // "agent-framework-hosted/{version}" in the User-Agent header. + var previousUserAgent = HostedAgentContext.UserAgentSupplement; + HostedAgentContext.UserAgentSupplement = HostedUserAgentValue; + + // 7. Set up consent context for -32006 OAuth consent interception. // We create a linked CTS so the consent-aware tool wrapper can cancel the agent // run mid-loop when a -32006 error is returned by the proxy. The RequestConsentState // is a shared mutable object that flows via AsyncLocal to the tool wrapper. @@ -184,7 +192,7 @@ public override async IAsyncEnumerable CreateAsync( var consentState = new RequestConsentState { CancellationSource = consentCts }; McpConsentContext.Current.Value = consentState; - // 7. Run the agent and convert output + // 8. Run the agent and convert output // NOTE: C# forbids 'yield return' inside a try block that has a catch clause, // and inside catch blocks. We use a flag to defer the yield to outside the try/catch. bool emittedTerminal = false; @@ -273,6 +281,10 @@ public override async IAsyncEnumerable CreateAsync( } finally { + // Restore the previous User-Agent supplement to avoid leaking state + // in case of nested handlers or execution context reuse. + HostedAgentContext.UserAgentSupplement = previousUserAgent; + await enumerator.DisposeAsync().ConfigureAwait(false); // Persist session after streaming completes (successful or not) @@ -376,4 +388,25 @@ private AgentSessionStore ResolveSessionStore(CreateResponse request) return agentName; } + + private static string CreateHostedUserAgentValue() + { + const string Name = "agent-framework-hosted"; + + if (typeof(AgentFrameworkResponseHandler).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/src/Microsoft.Agents.AI.Foundry/HostedAgentContext.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/HostedAgentContext.cs new file mode 100644 index 0000000000..c699bbd12f --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/HostedAgentContext.cs @@ -0,0 +1,42 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading; + +namespace Microsoft.Agents.AI; + +/// +/// Async-local context that enables the hosted-agent runtime to signal supplemental +/// User-Agent information to the outgoing +/// without requiring direct coupling between the policy and the hosting layer. +/// +/// +/// +/// When an agent is running inside a Foundry Hosted Agent, the hosting layer sets +/// to a string like "agent-framework-hosted/1.0.0". +/// The MEAI pipeline policy reads this value on each outgoing request and appends it to +/// the User-Agent header. +/// +/// +/// Because flows with the , +/// the value set in the hosting handler automatically propagates to all outgoing HTTP calls +/// made during that request, and is naturally scoped — concurrent requests do not interfere. +/// +/// +public static class HostedAgentContext +{ + private static readonly AsyncLocal s_userAgentSupplement = new(); + + /// + /// Gets or sets an optional supplemental User-Agent segment (e.g. "agent-framework-hosted/1.0.0") + /// that will be appended to the base MEAI User-Agent header on outgoing requests. + /// + /// + /// The supplemental User-Agent string, or when the agent is not + /// running in a hosted context. This value flows with the async execution context. + /// + public static string? UserAgentSupplement + { + get => s_userAgentSupplement.Value; + set => s_userAgentSupplement.Value = value; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/RequestOptionsExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/RequestOptionsExtensions.cs index 03e48e293b..ad3266578b 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry/RequestOptionsExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/RequestOptionsExtensions.cs @@ -46,8 +46,12 @@ public override ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList - message.Request.Headers.Add("User-Agent", s_userAgentValue); + private static void AddUserAgentHeader(PipelineMessage message) + { + var supplement = HostedAgentContext.UserAgentSupplement; + var value = supplement is null ? s_userAgentValue : $"{s_userAgentValue} {supplement}"; + message.Request.Headers.Add("User-Agent", value); + } private static string CreateUserAgentValue() { From 8cecd7cae90303176d4099f83456fcd936b823f5 Mon Sep 17 00:00:00 2001 From: alliscode Date: Thu, 23 Apr 2026 13:01:51 -0700 Subject: [PATCH 2/7] chore: update hosted UA format to foundry-hosting/agent-framework-dotnet/{version} Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../AgentFrameworkResponseHandler.cs | 2 +- dotnet/src/Microsoft.Agents.AI.Foundry/HostedAgentContext.cs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs index 75976a4927..82093495f0 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs @@ -391,7 +391,7 @@ private AgentSessionStore ResolveSessionStore(CreateResponse request) private static string CreateHostedUserAgentValue() { - const string Name = "agent-framework-hosted"; + const string Name = "foundry-hosting/agent-framework-dotnet"; if (typeof(AgentFrameworkResponseHandler).Assembly.GetCustomAttribute()?.InformationalVersion is string version) { diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/HostedAgentContext.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/HostedAgentContext.cs index c699bbd12f..afb0b5d247 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry/HostedAgentContext.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/HostedAgentContext.cs @@ -12,7 +12,7 @@ namespace Microsoft.Agents.AI; /// /// /// When an agent is running inside a Foundry Hosted Agent, the hosting layer sets -/// to a string like "agent-framework-hosted/1.0.0". +/// to a string like "foundry-hosting/agent-framework-dotnet/1.0.0". /// The MEAI pipeline policy reads this value on each outgoing request and appends it to /// the User-Agent header. /// @@ -27,7 +27,7 @@ public static class HostedAgentContext private static readonly AsyncLocal s_userAgentSupplement = new(); /// - /// Gets or sets an optional supplemental User-Agent segment (e.g. "agent-framework-hosted/1.0.0") + /// Gets or sets an optional supplemental User-Agent segment (e.g. "foundry-hosting/agent-framework-dotnet/1.0.0") /// that will be appended to the base MEAI User-Agent header on outgoing requests. /// /// From e68ff12d05e7afa550193bae58ad3ad92db02a92 Mon Sep 17 00:00:00 2001 From: alliscode Date: Fri, 24 Apr 2026 09:15:10 -0700 Subject: [PATCH 3/7] Trying to get UA flowing, no luck yet. --- .../AgentFrameworkResponseHandler.cs | 37 +------- .../ServiceCollectionExtensions.cs | 92 +++++++++++++++++++ .../HostedAgentContext.cs | 25 ++--- .../RequestOptionsExtensions.cs | 19 +++- 4 files changed, 119 insertions(+), 54 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs index 82093495f0..1c5a57eb49 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs @@ -3,7 +3,6 @@ using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; -using System.Reflection; using System.Runtime.CompilerServices; using System.Threading; using Azure.AI.AgentServer.Responses; @@ -23,8 +22,6 @@ namespace Microsoft.Agents.AI.Foundry.Hosting; [Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)] public class AgentFrameworkResponseHandler : ResponseHandler { - private static readonly string HostedUserAgentValue = CreateHostedUserAgentValue(); - private readonly IServiceProvider _serviceProvider; private readonly ILogger _logger; private readonly FoundryToolboxService? _toolboxService; @@ -179,12 +176,7 @@ public override async IAsyncEnumerable CreateAsync( var options = new ChatClientAgentRunOptions(chatOptions); - // 6. Set the hosted-agent User-Agent supplement so outgoing API calls include - // "agent-framework-hosted/{version}" in the User-Agent header. - var previousUserAgent = HostedAgentContext.UserAgentSupplement; - HostedAgentContext.UserAgentSupplement = HostedUserAgentValue; - - // 7. Set up consent context for -32006 OAuth consent interception. + // 6. Set up consent context for -32006 OAuth consent interception. // We create a linked CTS so the consent-aware tool wrapper can cancel the agent // run mid-loop when a -32006 error is returned by the proxy. The RequestConsentState // is a shared mutable object that flows via AsyncLocal to the tool wrapper. @@ -192,7 +184,7 @@ public override async IAsyncEnumerable CreateAsync( var consentState = new RequestConsentState { CancellationSource = consentCts }; McpConsentContext.Current.Value = consentState; - // 8. Run the agent and convert output + // 7. Run the agent and convert output // NOTE: C# forbids 'yield return' inside a try block that has a catch clause, // and inside catch blocks. We use a flag to defer the yield to outside the try/catch. bool emittedTerminal = false; @@ -281,10 +273,6 @@ public override async IAsyncEnumerable CreateAsync( } finally { - // Restore the previous User-Agent supplement to avoid leaking state - // in case of nested handlers or execution context reuse. - HostedAgentContext.UserAgentSupplement = previousUserAgent; - await enumerator.DisposeAsync().ConfigureAwait(false); // Persist session after streaming completes (successful or not) @@ -388,25 +376,4 @@ private AgentSessionStore ResolveSessionStore(CreateResponse request) return agentName; } - - private static string CreateHostedUserAgentValue() - { - const string Name = "foundry-hosting/agent-framework-dotnet"; - - if (typeof(AgentFrameworkResponseHandler).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/src/Microsoft.Agents.AI.Foundry.Hosting/ServiceCollectionExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ServiceCollectionExtensions.cs index dda822ef66..cd318159f5 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ServiceCollectionExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ServiceCollectionExtensions.cs @@ -1,10 +1,13 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.ClientModel.Primitives; +using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Reflection; using System.Threading.Tasks; using Azure.AI.AgentServer.Responses; +using Azure.AI.Projects; using Azure.Core; using Azure.Identity; using Microsoft.AspNetCore.Builder; @@ -49,6 +52,7 @@ public static class FoundryHostingExtensions public static IServiceCollection AddFoundryResponses(this IServiceCollection services) { ArgumentNullException.ThrowIfNull(services); + SetHostedUserAgent(); services.AddResponsesServer(); services.TryAddSingleton(); services.TryAddSingleton(); @@ -83,6 +87,7 @@ public static IServiceCollection AddFoundryResponses(this IServiceCollection ser { ArgumentNullException.ThrowIfNull(services); ArgumentNullException.ThrowIfNull(agent); + SetHostedUserAgent(); services.AddResponsesServer(); agentSessionStore ??= new InMemoryAgentSessionStore(); @@ -191,6 +196,25 @@ public static IEndpointRouteBuilder MapFoundryResponses(this IEndpointRouteBuild return endpoints; } + /// + /// Adds a pipeline policy to that appends the hosted-agent + /// identifier (foundry-hosting/agent-framework-dotnet/{version}) to the + /// User-Agent header on every outgoing HTTP request. + /// + /// + /// Call this method on the you pass to + /// so that outgoing API calls to Azure AI Foundry + /// include the hosted-agent telemetry header. + /// + /// The client options to configure. + /// The same instance for chaining. + public static AIProjectClientOptions AddHostedAgentTelemetry(this AIProjectClientOptions options) + { + ArgumentNullException.ThrowIfNull(options); + options.AddPolicy(new HostedUserAgentPolicy(GetHostedUserAgentValue()), PipelinePosition.BeforeTransport); + return options; + } + /// /// The ActivitySource name for the Responses hosting pipeline. /// Matches the value previously exposed by AgentHostTelemetry.ResponsesSourceName @@ -216,6 +240,74 @@ internal static AIAgent ApplyOpenTelemetry(AIAgent agent) .Build(); } + /// + /// Sets the global so that + /// MeaiUserAgentPolicy in the Foundry package appends the hosted-agent + /// identifier on code paths that use per-request . + /// Called once at service registration time. + /// + private static void SetHostedUserAgent() + { + HostedAgentContext.UserAgentSupplement ??= GetHostedUserAgentValue(); + } + + /// + /// Computes the "foundry-hosting/agent-framework-dotnet/{version}" string + /// from the hosting assembly's informational version. + /// + private static string GetHostedUserAgentValue() + { + const string Name = "foundry-hosting/agent-framework-dotnet"; + + if (typeof(FoundryHostingExtensions).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; + } + + /// Pipeline policy that appends the hosted-agent User-Agent segment to outgoing requests. + private sealed class HostedUserAgentPolicy(string userAgentValue) : PipelinePolicy + { + public override void Process(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) + { + this.AppendUserAgent(message); + ProcessNext(message, pipeline, currentIndex); + } + + public override ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) + { + this.AppendUserAgent(message); + return ProcessNextAsync(message, pipeline, currentIndex); + } + + private void AppendUserAgent(PipelineMessage message) + { + if (message.Request.Headers.TryGetValue("User-Agent", out var existing) && !string.IsNullOrEmpty(existing)) + { + // Guard against double-appending on retries. + if (!existing.Contains(userAgentValue, StringComparison.Ordinal)) + { + message.Request.Headers.Set("User-Agent", $"{existing} {userAgentValue}"); + } + } + else + { + message.Request.Headers.Set("User-Agent", userAgentValue); + } + } + } + private sealed class AgentFrameworkUserAgentMiddleware(RequestDelegate next) { private static readonly string s_userAgentValue = CreateUserAgentValue(); diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/HostedAgentContext.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/HostedAgentContext.cs index afb0b5d247..01daf244cc 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry/HostedAgentContext.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/HostedAgentContext.cs @@ -1,30 +1,21 @@ // Copyright (c) Microsoft. All rights reserved. -using System.Threading; - namespace Microsoft.Agents.AI; /// -/// Async-local context that enables the hosted-agent runtime to signal supplemental +/// Provides a process-wide context that enables the hosted-agent runtime to signal supplemental /// User-Agent information to the outgoing /// without requiring direct coupling between the policy and the hosting layer. /// /// -/// /// When an agent is running inside a Foundry Hosted Agent, the hosting layer sets -/// to a string like "foundry-hosting/agent-framework-dotnet/1.0.0". -/// The MEAI pipeline policy reads this value on each outgoing request and appends it to -/// the User-Agent header. -/// -/// -/// Because flows with the , -/// the value set in the hosting handler automatically propagates to all outgoing HTTP calls -/// made during that request, and is naturally scoped — concurrent requests do not interfere. -/// +/// at startup to a string like +/// "foundry-hosting/agent-framework-dotnet/1.0.0". The MEAI pipeline policy reads +/// this value on each outgoing request and appends it to the User-Agent header. /// public static class HostedAgentContext { - private static readonly AsyncLocal s_userAgentSupplement = new(); + private static volatile string? s_userAgentSupplement; /// /// Gets or sets an optional supplemental User-Agent segment (e.g. "foundry-hosting/agent-framework-dotnet/1.0.0") @@ -32,11 +23,11 @@ public static class HostedAgentContext /// /// /// The supplemental User-Agent string, or when the agent is not - /// running in a hosted context. This value flows with the async execution context. + /// running in a hosted context. This value is process-wide and typically set once at startup. /// public static string? UserAgentSupplement { - get => s_userAgentSupplement.Value; - set => s_userAgentSupplement.Value = value; + get => s_userAgentSupplement; + set => s_userAgentSupplement = value; } } diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/RequestOptionsExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/RequestOptionsExtensions.cs index ad3266578b..d66b62f6bb 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry/RequestOptionsExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/RequestOptionsExtensions.cs @@ -49,8 +49,23 @@ public override ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList Date: Thu, 30 Apr 2026 11:44:38 +0100 Subject: [PATCH 4/7] .NET: Polyfill MEAI OpenAIResponsesChatClient to add hosted-agent User-Agent supplement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When AgentFrameworkResponseHandler resolves an agent (i.e. we are running in a hosted context), TryApplyUserAgent walks the agent's IChatClient decorator chain to find MEAI's internal OpenAIResponsesChatClient and reflectively swaps its inner _responseClient field with a DelegatingResponsesClient wrapper. The wrapper overrides the public-virtual protocol methods to add a per-call HostedAgentUserAgentPolicy to the RequestOptions and delegate to the inner ResponsesClient. The OpenAI SDK's internal streaming overloads bottom out in calls to the public-virtual non-streaming overloads via virtual dispatch on this, so streaming is covered without overriding any non-virtual member. The wrapper accepts any ResponsesClient-derived inner — both the Foundry ProjectResponsesClient and the native OpenAI ResponsesClient — and preserves the inner client's full pipeline (Transport, RetryPolicy, NetworkTimeout, OrganizationId / ProjectId / UserAgentApplicationId, custom policies). - Add DelegatingResponsesClient + HostedAgentUserAgentPolicy in Microsoft.Agents.AI.Foundry.Hosting. - Add TryApplyUserAgent next to ApplyOpenTelemetry in FoundryHostingExtensions; wire it into AgentFrameworkResponseHandler.GetAgent for both keyed and default-agent paths. - Drop earlier-iteration dead code: AddHostedAgentTelemetry extension, HostedUserAgentPolicy class, HostedAgentContext.cs, and the never-called ToRequestOptions helper. - Revert RequestOptionsExtensions.MeaiUserAgentPolicy to MEAI-only (the supplement is now injected by the polyfill). - Revert unrelated whitespace change in Agent_Step25_ToolboxServerSideTools sample. - Tests cover streaming AND non-streaming, retry policy preservation, OrganizationId/ProjectId/UserAgentApplicationId pass-through, idempotency, native OpenAI ResponsesClient, and reflection guards for MEAI/OpenAI shape drift. --- .../Program.cs | 2 +- .../AgentFrameworkResponseHandler.cs | 6 +- .../DelegatingResponsesClient.cs | 113 +++++ .../HostedAgentUserAgentPolicy.cs | 84 ++++ .../ServiceCollectionExtensions.cs | 190 +++------ .../HostedAgentContext.cs | 33 -- .../RequestOptionsExtensions.cs | 38 +- .../Hosting/DelegatingResponsesClientTests.cs | 399 ++++++++++++++++++ .../Hosting/HostedOutboundUserAgentTests.cs | 165 ++++++++ .../ServiceCollectionExtensionsTests.cs | 53 +++ .../Hosting/UserAgentMiddlewareTests.cs | 134 ------ .../RequestOptionsExtensionsTests.cs | 115 +++++ 12 files changed, 1003 insertions(+), 329 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/DelegatingResponsesClient.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/HostedAgentUserAgentPolicy.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.Foundry/HostedAgentContext.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/DelegatingResponsesClientTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/HostedOutboundUserAgentTests.cs delete mode 100644 dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/UserAgentMiddlewareTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/RequestOptionsExtensionsTests.cs diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step25_ToolboxServerSideTools/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step25_ToolboxServerSideTools/Program.cs index 354c61441e..12731f4723 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step25_ToolboxServerSideTools/Program.cs +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step25_ToolboxServerSideTools/Program.cs @@ -23,7 +23,7 @@ // Replace with any question that exercises the tools configured in your toolbox. const string Query = "Introduce yourself and briefly describe the tools you can use to help me."; -string endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT_2") +string endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("Set FOUNDRY_PROJECT_ENDPOINT to your Foundry project endpoint."); string model = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini"; diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs index 1c5a57eb49..dacbbb7d4e 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs @@ -297,6 +297,7 @@ private AIAgent ResolveAgent(CreateResponse request) var agent = this._serviceProvider.GetKeyedService(agentName); if (agent is not null) { + FoundryHostingExtensions.TryApplyUserAgent(agent); return FoundryHostingExtensions.ApplyOpenTelemetry(agent); } @@ -310,12 +311,13 @@ private AIAgent ResolveAgent(CreateResponse request) var defaultAgent = this._serviceProvider.GetService(); if (defaultAgent is not null) { + FoundryHostingExtensions.TryApplyUserAgent(defaultAgent); return FoundryHostingExtensions.ApplyOpenTelemetry(defaultAgent); } var errorMessage = string.IsNullOrEmpty(agentName) ? "No agent name specified in the request (via agent.name or metadata[\"entity_id\"]) and no default AIAgent is registered." - : $"Agent '{agentName}' not found. Ensure it is registered via AddAIAgent(\"{agentName}\", ...) or as a default AIAgent."; + : $"Agent '{agentName}' not found. Ensure it is registered via AddFoundryResponses(services, agent) or services.AddKeyedSingleton(\"{agentName}\", ...)."; throw new InvalidOperationException(errorMessage); } @@ -352,7 +354,7 @@ private AgentSessionStore ResolveSessionStore(CreateResponse request) var errorMessage = string.IsNullOrEmpty(agentName) ? "No agent name specified in the request (via agent.name or metadata[\"entity_id\"]) and no default AgentSessionStore is registered." - : $"Agent '{agentName}' not found. Ensure it is registered via AddAIAgent(\"{agentName}\", ...) or as a default AgentSessionStore."; + : $"AgentSessionStore for agent '{agentName}' not found. Ensure it is registered via AddFoundryResponses(services, agent, agentSessionStore) or services.AddKeyedSingleton(\"{agentName}\", ...)."; throw new InvalidOperationException(errorMessage); } diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/DelegatingResponsesClient.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/DelegatingResponsesClient.cs new file mode 100644 index 0000000000..ebf878b49a --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/DelegatingResponsesClient.cs @@ -0,0 +1,113 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.ClientModel; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Threading.Tasks; +using OpenAI; +using OpenAI.Responses; + +#pragma warning disable OPENAI001, SCME0001 + +namespace Microsoft.Agents.AI.Foundry.Hosting; + +/// +/// A subclass that delegates every protocol-level request to a +/// wrapped . Before each call, a +/// is added to the per-call +/// so the wrapped client's pipeline appends the hosted-agent +/// User-Agent segment on the wire. +/// +/// +/// +/// The streaming overloads MEAI binds via reflection (internal CreateResponseStreamingAsync(CreateResponseOptions, RequestOptions) +/// and internal GetResponseStreamingAsync(GetResponseOptions, RequestOptions)) bottom out +/// in calls to the public-virtual non-streaming protocol overloads on . Overriding those +/// non-streaming overloads is therefore sufficient to intercept both streaming and non-streaming traffic. +/// +/// +/// The base pipeline supplied to +/// is a dummy pipeline whose terminal transport throws if invoked. Every override on this class +/// delegates to the inner client BEFORE any code path reaches , so the dummy is +/// never expected to run; the throwing transport surfaces any unexpected escape route loudly. +/// +/// +internal sealed class DelegatingResponsesClient : ResponsesClient +{ + private readonly ResponsesClient _inner; + + public DelegatingResponsesClient(ResponsesClient inner) + : base(BuildDummyPipeline(), new OpenAIClientOptions { Endpoint = inner?.Endpoint }) + { + this._inner = inner ?? throw new ArgumentNullException(nameof(inner)); + } + + public override async Task CreateResponseAsync(BinaryContent content, RequestOptions? options = null) + => await this._inner.CreateResponseAsync(content, AddUserAgentPolicy(options)).ConfigureAwait(false); + + public override ClientResult CreateResponse(BinaryContent content, RequestOptions? options = null) + => this._inner.CreateResponse(content, AddUserAgentPolicy(options)); + + public override async Task GetResponseAsync(string responseId, IEnumerable? include, bool? stream, int? startingAfter, bool? includeObfuscation, RequestOptions options) + => await this._inner.GetResponseAsync(responseId, include, stream, startingAfter, includeObfuscation, AddUserAgentPolicy(options)).ConfigureAwait(false); + + public override ClientResult GetResponse(string responseId, IEnumerable? include, bool? stream, int? startingAfter, bool? includeObfuscation, RequestOptions options) + => this._inner.GetResponse(responseId, include, stream, startingAfter, includeObfuscation, AddUserAgentPolicy(options)); + + public override async Task DeleteResponseAsync(string responseId, RequestOptions options) + => await this._inner.DeleteResponseAsync(responseId, AddUserAgentPolicy(options)).ConfigureAwait(false); + + public override ClientResult DeleteResponse(string responseId, RequestOptions options) + => this._inner.DeleteResponse(responseId, AddUserAgentPolicy(options)); + + public override async Task CancelResponseAsync(string responseId, RequestOptions options) + => await this._inner.CancelResponseAsync(responseId, AddUserAgentPolicy(options)).ConfigureAwait(false); + + public override ClientResult CancelResponse(string responseId, RequestOptions options) + => this._inner.CancelResponse(responseId, AddUserAgentPolicy(options)); + + public override async Task GetInputTokenCountAsync(string contentType, BinaryContent content, RequestOptions? options = null) + => await this._inner.GetInputTokenCountAsync(contentType, content, AddUserAgentPolicy(options)).ConfigureAwait(false); + + public override ClientResult GetInputTokenCount(string contentType, BinaryContent content, RequestOptions? options = null) + => this._inner.GetInputTokenCount(contentType, content, AddUserAgentPolicy(options)); + + public override async Task CompactResponseAsync(string contentType, BinaryContent content, RequestOptions? options = null) + => await this._inner.CompactResponseAsync(contentType, content, AddUserAgentPolicy(options)).ConfigureAwait(false); + + public override ClientResult CompactResponse(string contentType, BinaryContent content, RequestOptions? options = null) + => this._inner.CompactResponse(contentType, content, AddUserAgentPolicy(options)); + + public override async Task GetResponseInputItemCollectionPageAsync(string responseId, int? limit, string order, string after, string before, RequestOptions options) + => await this._inner.GetResponseInputItemCollectionPageAsync(responseId, limit, order, after, before, AddUserAgentPolicy(options)).ConfigureAwait(false); + + public override ClientResult GetResponseInputItemCollectionPage(string responseId, int? limit, string order, string after, string before, RequestOptions options) + => this._inner.GetResponseInputItemCollectionPage(responseId, limit, order, after, before, AddUserAgentPolicy(options)); + + private static RequestOptions AddUserAgentPolicy(RequestOptions? options) + { + options ??= new RequestOptions(); + options.AddPolicy(HostedAgentUserAgentPolicy.Instance, PipelinePosition.PerCall); + return options; + } + + private static ClientPipeline BuildDummyPipeline() + { + var options = new ClientPipelineOptions + { + Transport = new ThrowingTransport(), + }; + return ClientPipeline.Create(options, default, default, default); + } + + private sealed class ThrowingTransport : PipelineTransport + { + private const string Message = + "DelegatingResponsesClient transport invoked bypassed the override-and-delegate design. This exception should be unreachable and should never be thrown following the correct usage of DelegatingResponsesClient."; + + protected override PipelineMessage CreateMessageCore() => throw new InvalidOperationException(Message); + protected override void ProcessCore(PipelineMessage message) => throw new InvalidOperationException(Message); + protected override ValueTask ProcessCoreAsync(PipelineMessage message) => throw new InvalidOperationException(Message); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/HostedAgentUserAgentPolicy.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/HostedAgentUserAgentPolicy.cs new file mode 100644 index 0000000000..8d5d330471 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/HostedAgentUserAgentPolicy.cs @@ -0,0 +1,84 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Reflection; +using System.Threading.Tasks; + +namespace Microsoft.Agents.AI.Foundry.Hosting; + +/// +/// Pipeline policy that appends the hosted-agent User-Agent segment +/// (e.g. "foundry-hosting/agent-framework-dotnet/{version}") to outgoing requests. +/// +/// +/// +/// The supplement value is computed once from the Microsoft.Agents.AI.Foundry.Hosting +/// assembly's informational version. The policy is idempotent on retries: if the segment +/// is already present in the User-Agent header, the policy does not append it again. +/// +/// +/// This policy is added at request time (per-call ) +/// by when invoking the wrapped +/// . It is only registered when an agent is +/// resolved by the Foundry hosting layer. +/// +/// +internal sealed class HostedAgentUserAgentPolicy : PipelinePolicy +{ + public static HostedAgentUserAgentPolicy Instance { get; } = new HostedAgentUserAgentPolicy(); + + private static readonly string s_supplementValue = CreateSupplementValue(); + + public override void Process(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) + { + AppendHeader(message); + ProcessNext(message, pipeline, currentIndex); + } + + public override async ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) + { + AppendHeader(message); + await ProcessNextAsync(message, pipeline, currentIndex).ConfigureAwait(false); + } + + private static void AppendHeader(PipelineMessage message) + { + if (message.Request.Headers.TryGetValue("User-Agent", out var existing) && !string.IsNullOrEmpty(existing)) + { + // Guard against double-append on retries or when the policy + // is registered on multiple pipeline positions. + if (existing.Contains(s_supplementValue)) + { + return; + } + + message.Request.Headers.Set("User-Agent", $"{existing} {s_supplementValue}"); + } + else + { + message.Request.Headers.Set("User-Agent", s_supplementValue); + } + } + + private static string CreateSupplementValue() + { + const string Name = "foundry-hosting/agent-framework-dotnet"; + + if (typeof(HostedAgentUserAgentPolicy).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/src/Microsoft.Agents.AI.Foundry.Hosting/ServiceCollectionExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ServiceCollectionExtensions.cs index cd318159f5..38f70763a8 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ServiceCollectionExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ServiceCollectionExtensions.cs @@ -1,21 +1,17 @@ // Copyright (c) Microsoft. All rights reserved. using System; -using System.ClientModel.Primitives; -using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Reflection; -using System.Threading.Tasks; using Azure.AI.AgentServer.Responses; -using Azure.AI.Projects; using Azure.Core; using Azure.Identity; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Shared.DiagnosticIds; +using OpenAI.Responses; namespace Microsoft.Agents.AI.Foundry.Hosting; @@ -39,7 +35,7 @@ public static class FoundryHostingExtensions /// /// Example: /// - /// builder.AddAIAgent("my-agent", ...); + /// builder.Services.AddKeyedSingleton<AIAgent>("my-agent", myAgent); /// builder.Services.AddFoundryResponses(); /// /// var app = builder.Build(); @@ -52,7 +48,6 @@ public static class FoundryHostingExtensions public static IServiceCollection AddFoundryResponses(this IServiceCollection services) { ArgumentNullException.ThrowIfNull(services); - SetHostedUserAgent(); services.AddResponsesServer(); services.TryAddSingleton(); services.TryAddSingleton(); @@ -87,7 +82,6 @@ public static IServiceCollection AddFoundryResponses(this IServiceCollection ser { ArgumentNullException.ThrowIfNull(services); ArgumentNullException.ThrowIfNull(agent); - SetHostedUserAgent(); services.AddResponsesServer(); agentSessionStore ??= new InMemoryAgentSessionStore(); @@ -186,35 +180,9 @@ public static IEndpointRouteBuilder MapFoundryResponses(this IEndpointRouteBuild { ArgumentNullException.ThrowIfNull(endpoints); endpoints.MapResponsesServer(prefix); - - if (endpoints is IApplicationBuilder app) - { - // Ensure the middleware is added to the pipeline - app.UseMiddleware(); - } - return endpoints; } - /// - /// Adds a pipeline policy to that appends the hosted-agent - /// identifier (foundry-hosting/agent-framework-dotnet/{version}) to the - /// User-Agent header on every outgoing HTTP request. - /// - /// - /// Call this method on the you pass to - /// so that outgoing API calls to Azure AI Foundry - /// include the hosted-agent telemetry header. - /// - /// The client options to configure. - /// The same instance for chaining. - public static AIProjectClientOptions AddHostedAgentTelemetry(this AIProjectClientOptions options) - { - ArgumentNullException.ThrowIfNull(options); - options.AddPolicy(new HostedUserAgentPolicy(GetHostedUserAgentValue()), PipelinePosition.BeforeTransport); - return options; - } - /// /// The ActivitySource name for the Responses hosting pipeline. /// Matches the value previously exposed by AgentHostTelemetry.ResponsesSourceName @@ -241,113 +209,89 @@ internal static AIAgent ApplyOpenTelemetry(AIAgent agent) } /// - /// Sets the global so that - /// MeaiUserAgentPolicy in the Foundry package appends the hosted-agent - /// identifier on code paths that use per-request . - /// Called once at service registration time. - /// - private static void SetHostedUserAgent() - { - HostedAgentContext.UserAgentSupplement ??= GetHostedUserAgentValue(); - } - - /// - /// Computes the "foundry-hosting/agent-framework-dotnet/{version}" string - /// from the hosting assembly's informational version. + /// Attempts to wrap the agent's underlying + /// with a so every outgoing Responses-API request + /// carries the hosted-agent User-Agent segment. /// - private static string GetHostedUserAgentValue() + /// + /// + /// Best-effort and idempotent. The method is a no-op when: + /// + /// exposes no ; + /// the chat client is not backed by MEAI's internal OpenAIResponsesChatClient (e.g., a non-OpenAI provider or a custom impl); + /// the inner is already a . + /// + /// + /// + /// Works for any -derived inner client — both the Foundry-specific + /// and the native OpenAI + /// obtained from . The wrapper preserves + /// the inner client's pipeline (Transport, RetryPolicy, NetworkTimeout, OrganizationId / ProjectId / + /// UserAgentApplicationId, custom policies) because every override delegates to the inner instance. + /// + /// + /// Returns the same instance unchanged. Mutation happens via + /// reflection on MEAI's private _responseClient field; the agent itself is not wrapped. + /// + /// + internal static AIAgent TryApplyUserAgent(AIAgent agent) { - const string Name = "foundry-hosting/agent-framework-dotnet"; - - if (typeof(FoundryHostingExtensions).Assembly.GetCustomAttribute()?.InformationalVersion is string version) + if (agent is null) { - int pos = version.IndexOf('+'); - if (pos >= 0) - { - version = version.Substring(0, pos); - } - - if (version.Length > 0) - { - return $"{Name}/{version}"; - } + return agent!; } - return Name; - } - - /// Pipeline policy that appends the hosted-agent User-Agent segment to outgoing requests. - private sealed class HostedUserAgentPolicy(string userAgentValue) : PipelinePolicy - { - public override void Process(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) + var chatClient = agent.GetService(); + if (chatClient is null) { - this.AppendUserAgent(message); - ProcessNext(message, pipeline, currentIndex); + return agent; } - public override ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) + var meaiType = s_meaiResponsesChatClientType; + if (meaiType is null) { - this.AppendUserAgent(message); - return ProcessNextAsync(message, pipeline, currentIndex); + return agent; } - private void AppendUserAgent(PipelineMessage message) + var meaiInstance = chatClient.GetService(meaiType); + if (meaiInstance is null) { - if (message.Request.Headers.TryGetValue("User-Agent", out var existing) && !string.IsNullOrEmpty(existing)) - { - // Guard against double-appending on retries. - if (!existing.Contains(userAgentValue, StringComparison.Ordinal)) - { - message.Request.Headers.Set("User-Agent", $"{existing} {userAgentValue}"); - } - } - else - { - message.Request.Headers.Set("User-Agent", userAgentValue); - } + return agent; } - } - private sealed class AgentFrameworkUserAgentMiddleware(RequestDelegate next) - { - private static readonly string s_userAgentValue = CreateUserAgentValue(); - - public async Task InvokeAsync(HttpContext context) + var field = s_meaiResponseClientField; + if (field is null) { - var headers = context.Request.Headers; - var userAgent = headers.UserAgent.ToString(); - - if (string.IsNullOrEmpty(userAgent)) - { - headers.UserAgent = s_userAgentValue; - } - else if (!userAgent.Contains(s_userAgentValue, StringComparison.OrdinalIgnoreCase)) - { - headers.UserAgent = $"{userAgent} {s_userAgentValue}"; - } - - await next(context).ConfigureAwait(false); + return agent; } - private static string CreateUserAgentValue() + var current = field.GetValue(meaiInstance) as ResponsesClient; + if (current is null or DelegatingResponsesClient) { - const string Name = "agent-framework-dotnet"; + return agent; + } - if (typeof(AgentFrameworkUserAgentMiddleware).Assembly.GetCustomAttribute()?.InformationalVersion is string version) - { - int pos = version.IndexOf('+'); - if (pos >= 0) - { - version = version.Substring(0, pos); - } + field.SetValue(meaiInstance, new DelegatingResponsesClient(current)); + return agent; + } - if (version.Length > 0) - { - return $"{Name}/{version}"; - } - } + /// + /// MEAI's internal OpenAIResponsesChatClient type, resolved once via reflection. + /// if the type cannot be found (e.g., MEAI version drift). + /// + [UnconditionalSuppressMessage("Trimming", "IL2026:RequiresUnreferencedCode", + Justification = "MEAI's OpenAIResponsesChatClient is referenced through MicrosoftExtensionsAIResponsesExtensions and survives trimming.")] + [UnconditionalSuppressMessage("Trimming", "IL2073:RequiresUnreferencedCode", + Justification = "MEAI's OpenAIResponsesChatClient is referenced through MicrosoftExtensionsAIResponsesExtensions and survives trimming.")] + private static readonly Type? s_meaiResponsesChatClientType = + typeof(MicrosoftExtensionsAIResponsesExtensions).Assembly.GetType("Microsoft.Extensions.AI.OpenAIResponsesChatClient"); - return Name; - } - } + /// + /// MEAI's internal _responseClient field on OpenAIResponsesChatClient, + /// resolved once via reflection. if the field cannot be found. + /// + [UnconditionalSuppressMessage("Trimming", "IL2080:RequiresDynamicallyAccessedMembers", + Justification = "OpenAIResponsesChatClient and its private fields are preserved by the polyfill design; MEAI does the same reflection internally.")] + private static readonly FieldInfo? s_meaiResponseClientField = + s_meaiResponsesChatClientType?.GetField("_responseClient", BindingFlags.NonPublic | BindingFlags.Instance); } diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/HostedAgentContext.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/HostedAgentContext.cs deleted file mode 100644 index 01daf244cc..0000000000 --- a/dotnet/src/Microsoft.Agents.AI.Foundry/HostedAgentContext.cs +++ /dev/null @@ -1,33 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -namespace Microsoft.Agents.AI; - -/// -/// Provides a process-wide context that enables the hosted-agent runtime to signal supplemental -/// User-Agent information to the outgoing -/// without requiring direct coupling between the policy and the hosting layer. -/// -/// -/// When an agent is running inside a Foundry Hosted Agent, the hosting layer sets -/// at startup to a string like -/// "foundry-hosting/agent-framework-dotnet/1.0.0". The MEAI pipeline policy reads -/// this value on each outgoing request and appends it to the User-Agent header. -/// -public static class HostedAgentContext -{ - private static volatile string? s_userAgentSupplement; - - /// - /// Gets or sets an optional supplemental User-Agent segment (e.g. "foundry-hosting/agent-framework-dotnet/1.0.0") - /// that will be appended to the base MEAI User-Agent header on outgoing requests. - /// - /// - /// The supplemental User-Agent string, or when the agent is not - /// running in a hosted context. This value is process-wide and typically set once at startup. - /// - public static string? UserAgentSupplement - { - get => s_userAgentSupplement; - set => s_userAgentSupplement = value; - } -} diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/RequestOptionsExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/RequestOptionsExtensions.cs index d66b62f6bb..e00025b7ee 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry/RequestOptionsExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/RequestOptionsExtensions.cs @@ -3,7 +3,6 @@ using System.ClientModel.Primitives; using System.Collections.Generic; using System.Reflection; -using System.Threading; using System.Threading.Tasks; namespace Microsoft.Agents.AI; @@ -13,20 +12,6 @@ internal static class RequestOptionsExtensions /// Gets the singleton that adds a MEAI user-agent header. internal static PipelinePolicy UserAgentPolicy => MeaiUserAgentPolicy.Instance; - /// 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 { @@ -46,27 +31,8 @@ public override ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList + message.Request.Headers.Add("User-Agent", s_userAgentValue); private static string CreateUserAgentValue() { diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/DelegatingResponsesClientTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/DelegatingResponsesClientTests.cs new file mode 100644 index 0000000000..24a4460209 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/DelegatingResponsesClientTests.cs @@ -0,0 +1,399 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.ClientModel; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Reflection; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Azure.AI.Extensions.OpenAI; +using Microsoft.Agents.AI.Foundry.Hosting; +using Microsoft.Extensions.AI; +using OpenAI; +using OpenAI.Responses; + +#pragma warning disable OPENAI001, SCME0001, SCME0002, MEAI001 + +namespace Microsoft.Agents.AI.Foundry.UnitTests.Hosting; + +/// +/// Verifies that preserves user-supplied client options +/// (Transport, RetryPolicy, UserAgentApplicationId, OrganizationId, ProjectId) and adds the +/// hosted-agent User-Agent supplement on every outgoing request, including streaming. +/// Covers both the Azure-flavored and the native OpenAI +/// . +/// +public sealed class DelegatingResponsesClientTests +{ + private const string TestEndpoint = "https://fake-foundry.example.com/api/projects/fake-prj"; + private const string OpenAIEndpoint = "https://fake-openai.example.com/v1"; + private const string Deployment = "fake-deployment"; + + [Fact] + public async Task Polyfill_NonStreaming_PreservesAppId_ThroughCustomTransport_AddsSupplementAsync() + { + // Arrange + using var handler = new RecordingHandler(MinimalResponseJson()); +#pragma warning disable CA5399 + using var httpClient = new HttpClient(handler); +#pragma warning restore CA5399 + var inner = BuildInner(httpClient, userAgentApplicationId: "MY_APP_ID"); + var chat = MakeWithDelegating(inner); + + // Act + _ = await chat.GetResponseAsync("hello"); + + // Assert + var req = Assert.Single(handler.Requests); + Assert.Contains("MY_APP_ID", req.UserAgent); + Assert.Contains("MEAI/", req.UserAgent); + Assert.Contains("foundry-hosting/agent-framework-dotnet", req.UserAgent); + Assert.StartsWith(TestEndpoint, req.Uri); + } + + [Fact] + public async Task Polyfill_Streaming_PreservesAppId_ThroughCustomTransport_AddsSupplementAsync() + { + // Arrange + using var handler = new RecordingHandler(MinimalSseResponse()); +#pragma warning disable CA5399 + using var httpClient = new HttpClient(handler); +#pragma warning restore CA5399 + var inner = BuildInner(httpClient, userAgentApplicationId: "MY_APP_ID"); + var chat = MakeWithDelegating(inner); + + // Act + await foreach (var _ in chat.GetStreamingResponseAsync("hello")) + { + } + + // Assert + var req = Assert.Single(handler.Requests); + Assert.Contains("MY_APP_ID", req.UserAgent); + Assert.Contains("MEAI/", req.UserAgent); + Assert.Contains("foundry-hosting/agent-framework-dotnet", req.UserAgent); + Assert.StartsWith(TestEndpoint, req.Uri); + } + + [Fact] + public async Task Polyfill_PreservesOrganizationAndProjectHeadersAsync() + { + // Arrange + using var handler = new RecordingHandler(MinimalResponseJson()); +#pragma warning disable CA5399 + using var httpClient = new HttpClient(handler); +#pragma warning restore CA5399 + var inner = BuildInner(httpClient, + userAgentApplicationId: "MY_APP_ID", + organizationId: "org_xyz", + projectId: "proj_abc"); + var chat = MakeWithDelegating(inner); + + // Act + _ = await chat.GetResponseAsync("hello"); + + // Assert + var req = Assert.Single(handler.Requests); + Assert.Contains("MY_APP_ID", req.UserAgent); + Assert.Contains("foundry-hosting/agent-framework-dotnet", req.UserAgent); + } + + [Fact] + public async Task Polyfill_HonorsUserSuppliedRetryPolicy_ByCountingRetriesAsync() + { + // Arrange + var retryPolicy = new CountingRetryPolicy(extraAttempts: 2); + using var handler = new RecordingHandler(MinimalResponseJson()); +#pragma warning disable CA5399 + using var httpClient = new HttpClient(handler); +#pragma warning restore CA5399 + var inner = BuildInner(httpClient, userAgentApplicationId: "MY_APP_ID", retryPolicy: retryPolicy); + var chat = MakeWithDelegating(inner); + + // Act + _ = await chat.GetResponseAsync("hello"); + + // Assert: retry policy ran (1 + 2 extras = 3 attempts). + Assert.Equal(3, handler.Requests.Count); + Assert.Equal(3, retryPolicy.InvocationCount); + foreach (var req in handler.Requests) + { + Assert.Contains("MY_APP_ID", req.UserAgent); + Assert.Contains("MEAI/", req.UserAgent); + Assert.Contains("foundry-hosting/agent-framework-dotnet", req.UserAgent); + } + } + + [Fact] + public async Task Baseline_NonStreaming_DoesNotInjectSupplementAsync() + { + // Arrange + using var handler = new RecordingHandler(MinimalResponseJson()); +#pragma warning disable CA5399 + using var httpClient = new HttpClient(handler); +#pragma warning restore CA5399 + var inner = BuildInner(httpClient, userAgentApplicationId: "MY_APP_ID"); + var chat = inner.AsIChatClient(Deployment); + + // Act + _ = await chat.GetResponseAsync("hello"); + + // Assert + var req = Assert.Single(handler.Requests); + Assert.Contains("MY_APP_ID", req.UserAgent); + Assert.Contains("MEAI/", req.UserAgent); + Assert.DoesNotContain("foundry-hosting/agent-framework-dotnet", req.UserAgent); + } + + [Fact] + public async Task Polyfill_NativeOpenAIResponsesClient_NonStreaming_AddsSupplementAsync() + { + // Arrange: use the NATIVE OpenAI SDK ResponsesClient (no Foundry / Azure project involved). + using var handler = new RecordingHandler(MinimalResponseJson()); +#pragma warning disable CA5399 + using var httpClient = new HttpClient(handler); +#pragma warning restore CA5399 + var inner = BuildOpenAIInner(httpClient, userAgentApplicationId: "MY_APP_ID"); + var chat = MakeWithDelegating(inner); + + // Act + _ = await chat.GetResponseAsync("hello"); + + // Assert + var req = Assert.Single(handler.Requests); + Assert.Contains("MY_APP_ID", req.UserAgent); + Assert.Contains("MEAI/", req.UserAgent); + Assert.Contains("foundry-hosting/agent-framework-dotnet", req.UserAgent); + Assert.StartsWith(OpenAIEndpoint, req.Uri); + } + + [Fact] + public async Task Polyfill_NativeOpenAIResponsesClient_Streaming_AddsSupplementAsync() + { + // Arrange + using var handler = new RecordingHandler(MinimalSseResponse()); +#pragma warning disable CA5399 + using var httpClient = new HttpClient(handler); +#pragma warning restore CA5399 + var inner = BuildOpenAIInner(httpClient, userAgentApplicationId: "MY_APP_ID"); + var chat = MakeWithDelegating(inner); + + // Act + await foreach (var _ in chat.GetStreamingResponseAsync("hello")) + { + } + + // Assert + var req = Assert.Single(handler.Requests); + Assert.Contains("MY_APP_ID", req.UserAgent); + Assert.Contains("MEAI/", req.UserAgent); + Assert.Contains("foundry-hosting/agent-framework-dotnet", req.UserAgent); + Assert.StartsWith(OpenAIEndpoint, req.Uri); + } + + [Theory] + [InlineData("DeleteResponseAsync")] + [InlineData("CancelResponseAsync")] + [InlineData("GetInputTokenCountAsync")] + [InlineData("CompactResponseAsync")] + [InlineData("GetResponseInputItemCollectionPageAsync")] + public async Task Polyfill_AncillaryProtocolMethod_AddsSupplementAsync(string method) + { + // Arrange: hit the wrapper DIRECTLY (no MEAI in the chain) to simulate user code that + // grabs the underlying ResponsesClient via chat.GetService() and invokes + // a non-Create/Get protocol method. This is the regression path: without overriding these, + // the wrapper's dummy throwing pipeline would fire. + using var handler = new RecordingHandler(MinimalResponseJson()); +#pragma warning disable CA5399 + using var httpClient = new HttpClient(handler); +#pragma warning restore CA5399 + var inner = BuildOpenAIInner(httpClient, userAgentApplicationId: "MY_APP_ID"); + var wrapper = new DelegatingResponsesClient(inner); + + // Act + switch (method) + { + case "DeleteResponseAsync": + _ = await wrapper.DeleteResponseAsync("resp_1", options: null!); + break; + case "CancelResponseAsync": + _ = await wrapper.CancelResponseAsync("resp_1", options: null!); + break; + case "GetInputTokenCountAsync": + _ = await wrapper.GetInputTokenCountAsync("application/json", BinaryContent.Create(BinaryData.FromString("{}"))); + break; + case "CompactResponseAsync": + _ = await wrapper.CompactResponseAsync("application/json", BinaryContent.Create(BinaryData.FromString("{}"))); + break; + case "GetResponseInputItemCollectionPageAsync": + _ = await wrapper.GetResponseInputItemCollectionPageAsync("resp_1", limit: null, order: "asc", after: "a", before: "b", options: null!); + break; + default: + Assert.Fail($"Unhandled method: {method}"); + break; + } + + // Assert + var req = Assert.Single(handler.Requests); + Assert.Contains("MY_APP_ID", req.UserAgent); + Assert.Contains("foundry-hosting/agent-framework-dotnet", req.UserAgent); + } + + [Fact] + public void OpenAIResponsesChatClient_ResponseClientField_ReflectionGuard() + { + // Guards the polyfill's reflection target. Failure here means MEAI internals + // changed and the polyfill needs updating. + var meaiType = typeof(MicrosoftExtensionsAIResponsesExtensions).Assembly + .GetType("Microsoft.Extensions.AI.OpenAIResponsesChatClient"); + Assert.NotNull(meaiType); + + var field = meaiType!.GetField("_responseClient", BindingFlags.NonPublic | BindingFlags.Instance); + Assert.NotNull(field); + Assert.True(typeof(ResponsesClient).IsAssignableFrom(field!.FieldType), + $"Expected _responseClient to be assignable to ResponsesClient but was {field.FieldType}."); + } + + [Fact] + public void ResponsesClient_PipelineProperty_ReflectionGuard() + { + // The polyfill design assumes ResponsesClient.Pipeline remains accessible. + var pipelineProp = typeof(ResponsesClient).GetProperty("Pipeline", BindingFlags.Public | BindingFlags.Instance); + Assert.NotNull(pipelineProp); + Assert.Equal(typeof(ClientPipeline), pipelineProp!.PropertyType); + } + + private static IChatClient MakeWithDelegating(ResponsesClient inner) + { + IChatClient meai = inner.AsIChatClient(Deployment); + var meaiType = meai.GetType(); + var field = meaiType.GetField("_responseClient", BindingFlags.NonPublic | BindingFlags.Instance)!; + field.SetValue(meai, new DelegatingResponsesClient(inner)); + return meai; + } + + private static ProjectResponsesClient BuildInner( + HttpClient httpClient, + string? userAgentApplicationId = null, + string? organizationId = null, + string? projectId = null, + PipelinePolicy? retryPolicy = null) + { + var options = new ProjectResponsesClientOptions + { + Transport = new HttpClientPipelineTransport(httpClient), + }; + if (userAgentApplicationId is not null) + { + options.UserAgentApplicationId = userAgentApplicationId; + } + if (organizationId is not null) + { + options.OrganizationId = organizationId; + } + if (projectId is not null) + { + options.ProjectId = projectId; + } + if (retryPolicy is not null) + { + options.RetryPolicy = retryPolicy; + } + + return new ProjectResponsesClient(new Uri(TestEndpoint), new FakeAuthenticationTokenProvider(), options); + } + + private static ResponsesClient BuildOpenAIInner( + HttpClient httpClient, + string? userAgentApplicationId = null) + { + var options = new OpenAIClientOptions + { + Transport = new HttpClientPipelineTransport(httpClient), + Endpoint = new Uri(OpenAIEndpoint), + }; + if (userAgentApplicationId is not null) + { + options.UserAgentApplicationId = userAgentApplicationId; + } + + return new ResponsesClient(new ApiKeyCredential("test-key"), options); + } + + private static string MinimalResponseJson() => """ + { + "id":"resp_1","object":"response","created_at":1700000000,"status":"completed", + "model":"fake","output":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2} + } + """; + + private static string MinimalSseResponse() + { + var sb = new StringBuilder(); + sb.Append("event: response.completed\n"); + sb.Append("data: ").Append("""{"type":"response.completed","response":{"id":"resp_1","object":"response","created_at":1700000000,"status":"completed","model":"fake","output":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}}""").Append("\n\n"); + sb.Append("data: [DONE]\n\n"); + return sb.ToString(); + } + + private sealed class RecordingHandler : HttpClientHandler + { + private readonly string _body; + public List Requests { get; } = []; + + public RecordingHandler(string body) + { + this._body = body; + } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + string ua = request.Headers.TryGetValues("User-Agent", out var values) + ? string.Join(",", values) + : "(none)"; + this.Requests.Add(new RecordedRequest(request.Method.Method, request.RequestUri?.ToString() ?? "?", ua)); + + var resp = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(this._body, Encoding.UTF8, "application/json"), + RequestMessage = request, + }; + return Task.FromResult(resp); + } + } + + private readonly record struct RecordedRequest(string Method, string Uri, string UserAgent); + + private sealed class CountingRetryPolicy : PipelinePolicy + { + private readonly int _extraAttempts; + public int InvocationCount { get; private set; } + + public CountingRetryPolicy(int extraAttempts) + { + this._extraAttempts = extraAttempts; + } + + public override void Process(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) + { + for (int i = 0; i <= this._extraAttempts; i++) + { + this.InvocationCount++; + ProcessNext(message, pipeline, currentIndex); + } + } + + public override async ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) + { + for (int i = 0; i <= this._extraAttempts; i++) + { + this.InvocationCount++; + await ProcessNextAsync(message, pipeline, currentIndex).ConfigureAwait(false); + } + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/HostedOutboundUserAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/HostedOutboundUserAgentTests.cs new file mode 100644 index 0000000000..30c4bef9de --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/HostedOutboundUserAgentTests.cs @@ -0,0 +1,165 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Azure.AI.Extensions.OpenAI; +using Microsoft.Agents.AI.Foundry.Hosting; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; + +#pragma warning disable OPENAI001, SCME0001, SCME0002, MEAI001 + +namespace Microsoft.Agents.AI.Foundry.UnitTests.Hosting; + +/// +/// End-to-end tests that exercise the FULL hosted ASP.NET Core pipeline: +/// inbound HTTP → MapFoundryResponses → AgentFrameworkResponseHandler → TryApplyUserAgent → +/// agent invocation → outbound HTTP from inside the hosted environment. +/// Verifies that the hosted-agent User-Agent supplement reaches the outbound wire, +/// not just the inbound request. +/// +public sealed class HostedOutboundUserAgentTests : IAsyncDisposable +{ + private const string TestEndpoint = "https://fake-foundry.example.com/api/projects/fake-prj"; + private const string Deployment = "fake-deployment"; + + private WebApplication? _app; + private HttpClient? _inboundClient; + private RecordingHandler? _outboundHandler; + + public async ValueTask DisposeAsync() + { + this._inboundClient?.Dispose(); + this._outboundHandler?.Dispose(); + if (this._app is not null) + { + await this._app.DisposeAsync(); + } + } + + [Fact] + public async Task Hosted_InboundResponsesRequest_TriggersOutboundCall_WithFoundryHostingSupplementAsync() + { + // Arrange: spin up a real ASP.NET Core TestServer that hosts an AIAgent backed by MEAI's + // OpenAIResponsesChatClient → ProjectResponsesClient → fake HTTP transport. This is the + // exact production stack minus the network: the only thing not real is the wire transport. + await this.StartHostedServerAsync(); + + // Act: send an inbound /openai/v1/responses request as the Foundry runtime would. + using var inboundRequest = new HttpRequestMessage(HttpMethod.Post, "/responses") + { + Content = new StringContent(InboundResponsesRequestJson(), Encoding.UTF8, "application/json"), + }; + using var inboundResponse = await this._inboundClient!.SendAsync(inboundRequest); + var inboundBody = await inboundResponse.Content.ReadAsStringAsync(); + + // Assert: at least one OUTBOUND request reached the fake transport, AND it carries the + // foundry-hosting/agent-framework-dotnet/{version} supplement on its User-Agent. + // (We don't care about the inbound response shape — only that the agent's call to MEAI + // triggered an outbound request whose UA reaches the sandbox boundary correctly.) + Assert.True(this._outboundHandler!.Requests.Count > 0, + $"Expected at least one outbound request. Inbound status: {(int)inboundResponse.StatusCode}, body: {inboundBody}"); + var outbound = this._outboundHandler.Requests[0]; + Assert.StartsWith(TestEndpoint, outbound.Uri); + Assert.Contains("MEAI/", outbound.UserAgent); + Assert.Contains("foundry-hosting/agent-framework-dotnet", outbound.UserAgent); + } + + private async Task StartHostedServerAsync() + { + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + + // Build a real ChatClientAgent whose IChatClient is MEAI's OpenAIResponsesChatClient + // wrapping a ProjectResponsesClient backed by a fake HTTP handler. After AgentFrameworkResponseHandler + // resolves this agent, TryApplyUserAgent will swap the inner _responseClient with our wrapper. + this._outboundHandler = new RecordingHandler(MinimalResponseJson()); +#pragma warning disable CA5399 + var outboundHttpClient = new HttpClient(this._outboundHandler); +#pragma warning restore CA5399 + + var projectOptions = new ProjectResponsesClientOptions + { + Transport = new HttpClientPipelineTransport(outboundHttpClient), + }; + var projectResponsesClient = new ProjectResponsesClient( + new Uri(TestEndpoint), + new FakeAuthenticationTokenProvider(), + projectOptions); + + IChatClient chatClient = projectResponsesClient.AsIChatClient(Deployment); + AIAgent agent = new ChatClientAgent(chatClient); + + builder.Services.AddFoundryResponses(agent); + builder.Services.AddLogging(); + + this._app = builder.Build(); + this._app.MapFoundryResponses(); + + await this._app.StartAsync(); + + var testServer = this._app.Services.GetRequiredService() as TestServer + ?? throw new InvalidOperationException("TestServer not found"); + + this._inboundClient = testServer.CreateClient(); + } + + private static string InboundResponsesRequestJson() => """ + { + "model": "fake-deployment", + "input": [ + { + "type": "message", + "id": "msg_1", + "status": "completed", + "role": "user", + "content": [{ "type": "input_text", "text": "Hello" }] + } + ] + } + """; + + private static string MinimalResponseJson() => """ + { + "id":"resp_1","object":"response","created_at":1700000000,"status":"completed", + "model":"fake","output":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2} + } + """; + + private sealed class RecordingHandler : HttpClientHandler + { + private readonly string _body; + public List Requests { get; } = []; + + public RecordingHandler(string body) + { + this._body = body; + } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + string ua = request.Headers.TryGetValues("User-Agent", out var values) + ? string.Join(",", values) + : "(none)"; + this.Requests.Add(new RecordedRequest(request.RequestUri?.ToString() ?? "?", ua)); + + var resp = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(this._body, Encoding.UTF8, "application/json"), + RequestMessage = request, + }; + return Task.FromResult(resp); + } + } + + private readonly record struct RecordedRequest(string Uri, string UserAgent); +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/ServiceCollectionExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/ServiceCollectionExtensionsTests.cs index aadca65643..b443ab59ca 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/ServiceCollectionExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/ServiceCollectionExtensionsTests.cs @@ -4,8 +4,10 @@ using System.Linq; using Azure.AI.AgentServer.Responses; using Microsoft.Agents.AI.Foundry.Hosting; +using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; using Moq; +using OpenAI.Responses; namespace Microsoft.Agents.AI.Foundry.UnitTests.Hosting; @@ -93,4 +95,55 @@ public void ApplyOpenTelemetry_AlreadyInstrumentedAgent_ReturnsSameReference() Assert.Same(instrumented, result); } + + [Fact] + public void TryApplyUserAgent_AgentWithoutChatClient_NoOp() + { + // Arrange: agent.GetService() returns null. + var mockAgent = new Mock(); + + // Act + var result = FoundryHostingExtensions.TryApplyUserAgent(mockAgent.Object); + + // Assert + Assert.Same(mockAgent.Object, result); + } + + [Fact] + public void TryApplyUserAgent_AgentWithNonMeaiChatClient_NoOp() + { + // Arrange: chat client that does not return MEAI's OpenAIResponsesChatClient via GetService. + var mockChatClient = new Mock(); + mockChatClient.Setup(c => c.GetService(It.IsAny(), It.IsAny())).Returns(null!); + + var mockAgent = new Mock(); + mockAgent.Setup(a => a.GetService(typeof(IChatClient), It.IsAny())).Returns(mockChatClient.Object); + + // Act + var result = FoundryHostingExtensions.TryApplyUserAgent(mockAgent.Object); + + // Assert + Assert.Same(mockAgent.Object, result); + } + + [Fact] + public void TryApplyUserAgent_NullAgent_ReturnsNullWithoutThrowing() + { + // Arrange + Act + var result = FoundryHostingExtensions.TryApplyUserAgent(null!); + + // Assert + Assert.Null(result); + } + + [Fact] + public void MeaiOpenAIResponsesChatClient_TypeFullName_ReflectionGuard() + { + // Guards the polyfill's reflection target type-name. + var meaiType = typeof(MicrosoftExtensionsAIResponsesExtensions).Assembly + .GetType("Microsoft.Extensions.AI.OpenAIResponsesChatClient"); + Assert.NotNull(meaiType); + Assert.True(typeof(IChatClient).IsAssignableFrom(meaiType!), + $"Expected MEAI {meaiType!.FullName} to implement IChatClient."); + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/UserAgentMiddlewareTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/UserAgentMiddlewareTests.cs deleted file mode 100644 index 008e3f3347..0000000000 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/UserAgentMiddlewareTests.cs +++ /dev/null @@ -1,134 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Net.Http; -using System.Text.RegularExpressions; -using System.Threading.Tasks; -using Microsoft.Agents.AI.Foundry.Hosting; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting.Server; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.TestHost; -using Microsoft.Extensions.DependencyInjection; -using Moq; - -namespace Microsoft.Agents.AI.Foundry.UnitTests.Hosting; - -/// -/// Tests for the AgentFrameworkUserAgentMiddleware registered by -/// . -/// -public sealed partial class UserAgentMiddlewareTests : IAsyncDisposable -{ - private const string VersionedUserAgentPattern = @"agent-framework-dotnet/\d+\.\d+\.\d+(-[\w.]+)?"; - - private WebApplication? _app; - private HttpClient? _httpClient; - - public async ValueTask DisposeAsync() - { - this._httpClient?.Dispose(); - if (this._app != null) - { - await this._app.DisposeAsync(); - } - } - - [Fact] - public async Task MapFoundryResponses_NoUserAgentHeader_SetsAgentFrameworkUserAgentAsync() - { - // Arrange - await this.CreateTestServerAsync(); - - using var request = new HttpRequestMessage(HttpMethod.Get, "/test-ua"); - - // Act - var response = await this._httpClient!.SendAsync(request); - var userAgent = await response.Content.ReadAsStringAsync(); - - // Assert - Assert.Matches(VersionedUserAgentPattern, userAgent); - } - - [Fact] - public async Task MapFoundryResponses_WithExistingUserAgent_AppendsAgentFrameworkUserAgentAsync() - { - // Arrange - await this.CreateTestServerAsync(); - - using var request = new HttpRequestMessage(HttpMethod.Get, "/test-ua"); - request.Headers.TryAddWithoutValidation("User-Agent", "MyApp/1.0"); - - // Act - var response = await this._httpClient!.SendAsync(request); - var userAgent = await response.Content.ReadAsStringAsync(); - - // Assert - Assert.StartsWith("MyApp/1.0", userAgent); - Assert.Matches(VersionedUserAgentPattern, userAgent); - } - - [Fact] - public async Task MapFoundryResponses_AlreadyContainsUserAgent_DoesNotDuplicateAsync() - { - // Arrange - await this.CreateTestServerAsync(); - - // First request to capture the actual middleware-generated value - using var firstRequest = new HttpRequestMessage(HttpMethod.Get, "/test-ua"); - var firstResponse = await this._httpClient!.SendAsync(firstRequest); - var middlewareValue = await firstResponse.Content.ReadAsStringAsync(); - - // Act: send a second request that already contains the middleware value - using var secondRequest = new HttpRequestMessage(HttpMethod.Get, "/test-ua"); - secondRequest.Headers.TryAddWithoutValidation("User-Agent", $"MyApp/2.0 {middlewareValue}"); - var secondResponse = await this._httpClient!.SendAsync(secondRequest); - var userAgent = await secondResponse.Content.ReadAsStringAsync(); - - // Assert: should remain unchanged (no duplication) - Assert.Equal($"MyApp/2.0 {middlewareValue}", userAgent); - Assert.Single(VersionedUserAgentRegex().Matches(userAgent)); - } - - [Fact] - public async Task MapFoundryResponses_UserAgentValue_ContainsVersionAsync() - { - // Arrange - await this.CreateTestServerAsync(); - - using var request = new HttpRequestMessage(HttpMethod.Get, "/test-ua"); - - // Act - var response = await this._httpClient!.SendAsync(request); - var userAgent = await response.Content.ReadAsStringAsync(); - - // Assert: should match "agent-framework-dotnet/x.y.z" pattern - Assert.Matches(VersionedUserAgentPattern, userAgent); - } - - private async Task CreateTestServerAsync() - { - var builder = WebApplication.CreateBuilder(); - builder.WebHost.UseTestServer(); - - var mockAgent = new Mock(); - builder.Services.AddFoundryResponses(mockAgent.Object); - - this._app = builder.Build(); - this._app.MapFoundryResponses(); - - // Test endpoint that echoes the User-Agent header after middleware processing - this._app.MapGet("/test-ua", (HttpContext ctx) => - Results.Text(ctx.Request.Headers.UserAgent.ToString())); - - await this._app.StartAsync(); - - var testServer = this._app.Services.GetRequiredService() as TestServer - ?? throw new InvalidOperationException("TestServer not found"); - - this._httpClient = testServer.CreateClient(); - } - - [GeneratedRegex(VersionedUserAgentPattern)] - private static partial Regex VersionedUserAgentRegex(); -} diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/RequestOptionsExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/RequestOptionsExtensionsTests.cs new file mode 100644 index 0000000000..18824c4875 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/RequestOptionsExtensionsTests.cs @@ -0,0 +1,115 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.ClientModel.Primitives; +using System.Net; +using System.Net.Http; +using System.Reflection; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Agents.AI.Foundry.UnitTests; + +/// +/// Verifies the per-call MeaiUserAgentPolicy exposed via +/// . The policy is reachable through the +/// public constructors (which add it to the internally-built +/// 's pipeline), so its behavior is part of the +/// public API surface. +/// +public sealed class RequestOptionsExtensionsTests +{ + [Fact] + public async Task MeaiUserAgentPolicy_AddsMeaiSegment_ToOutgoingRequestAsync() + { + // Arrange + using var handler = new RecordingHandler(); +#pragma warning disable CA5399 + using var httpClient = new HttpClient(handler); +#pragma warning restore CA5399 + var pipeline = ClientPipeline.Create( + new ClientPipelineOptions { Transport = new HttpClientPipelineTransport(httpClient) }, + perCallPolicies: [RequestOptionsExtensions.UserAgentPolicy], + perTryPolicies: default, + beforeTransportPolicies: default); + + // Act + var message = pipeline.CreateMessage(); + message.Request.Method = "POST"; + message.Request.Uri = new System.Uri("https://example.test/anything"); + await pipeline.SendAsync(message); + + // Assert + Assert.Equal(1, handler.Count); + Assert.NotNull(handler.LastUserAgent); + Assert.Contains("MEAI/", handler.LastUserAgent); + } + + [Fact] + public async Task MeaiUserAgentPolicy_DoesNotAddFoundryHostingSegmentAsync() + { + // Arrange + using var handler = new RecordingHandler(); +#pragma warning disable CA5399 + using var httpClient = new HttpClient(handler); +#pragma warning restore CA5399 + var pipeline = ClientPipeline.Create( + new ClientPipelineOptions { Transport = new HttpClientPipelineTransport(httpClient) }, + perCallPolicies: [RequestOptionsExtensions.UserAgentPolicy], + perTryPolicies: default, + beforeTransportPolicies: default); + + // Act + var message = pipeline.CreateMessage(); + message.Request.Method = "POST"; + message.Request.Uri = new System.Uri("https://example.test/anything"); + await pipeline.SendAsync(message); + + // Assert: the policy is MEAI-only; the foundry-hosting supplement is added elsewhere + // (by the polyfill DelegatingResponsesClient → HostedAgentUserAgentPolicy). + Assert.NotNull(handler.LastUserAgent); + Assert.DoesNotContain("foundry-hosting/agent-framework-dotnet", handler.LastUserAgent); + } + + [Fact] + public void UserAgentPolicy_ExposesSingletonInstance() + { + // Two reads of the static property must return the same instance — the policy is stateless and shared. + var first = RequestOptionsExtensions.UserAgentPolicy; + var second = RequestOptionsExtensions.UserAgentPolicy; + Assert.Same(first, second); + } + + [Fact] + public void MeaiUserAgentPolicy_ValueIncludesAFFoundryAssemblyVersion_ReflectionGuard() + { + // The policy emits "MEAI/{Microsoft.Agents.AI.Foundry assembly InformationalVersion}". + // If the assembly metadata stops being readable, the policy falls back to "MEAI" without a version, + // which is a measurable telemetry regression. + var attr = typeof(RequestOptionsExtensions).Assembly + .GetCustomAttribute(); + Assert.NotNull(attr); + Assert.False(string.IsNullOrEmpty(attr!.InformationalVersion)); + } + + private sealed class RecordingHandler : HttpClientHandler + { + public int Count { get; private set; } + public string? LastUserAgent { get; private set; } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + this.Count++; + this.LastUserAgent = request.Headers.TryGetValues("User-Agent", out var values) + ? string.Join(",", values) + : null; + + var resp = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("{}", Encoding.UTF8, "application/json"), + RequestMessage = request, + }; + return Task.FromResult(resp); + } + } +} From 6610830bf04376e65e28143c91207854f0ea4287 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> Date: Thu, 30 Apr 2026 13:23:04 +0100 Subject: [PATCH 5/7] .NET: Address review feedback on hosted-agent User-Agent polyfill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TryApplyUserAgent: replace silent null-return with ArgumentNullException to match the codebase's convention. - Add idempotency test (TryApplyUserAgent_CalledTwiceOnSameAgent_DoesNotDoubleWrap) — runs the polyfill twice on the same agent and asserts the wire UA contains exactly one foundry-hosting segment, proving the 'current is DelegatingResponsesClient' guard prevents nested wrapping. - Add retry-double-append test (Polyfill_RetryWithinCall_DoesNotDuplicateSupplementInUserAgent) — exercises the HostedAgentUserAgentPolicy Contains-guard via a custom retry policy that re-runs the inner pipeline on the same message. - Replace TryApplyUserAgent_NullAgent_ReturnsNullWithoutThrowing with TryApplyUserAgent_NullAgent_ThrowsArgumentNullException to match the new contract. --- .../ServiceCollectionExtensions.cs | 5 +- .../Hosting/DelegatingResponsesClientTests.cs | 56 ++++++++++++++++++- .../ServiceCollectionExtensionsTests.cs | 17 +++--- 3 files changed, 63 insertions(+), 15 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ServiceCollectionExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ServiceCollectionExtensions.cs index 38f70763a8..6c7b15234d 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ServiceCollectionExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ServiceCollectionExtensions.cs @@ -236,10 +236,7 @@ internal static AIAgent ApplyOpenTelemetry(AIAgent agent) /// internal static AIAgent TryApplyUserAgent(AIAgent agent) { - if (agent is null) - { - return agent!; - } + ArgumentNullException.ThrowIfNull(agent); var chatClient = agent.GetService(); if (chatClient is null) diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/DelegatingResponsesClientTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/DelegatingResponsesClientTests.cs index 24a4460209..8ec8a7f570 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/DelegatingResponsesClientTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/DelegatingResponsesClientTests.cs @@ -27,12 +27,15 @@ namespace Microsoft.Agents.AI.Foundry.UnitTests.Hosting; /// Covers both the Azure-flavored and the native OpenAI /// . /// -public sealed class DelegatingResponsesClientTests +public sealed partial class DelegatingResponsesClientTests { private const string TestEndpoint = "https://fake-foundry.example.com/api/projects/fake-prj"; private const string OpenAIEndpoint = "https://fake-openai.example.com/v1"; private const string Deployment = "fake-deployment"; + [System.Text.RegularExpressions.GeneratedRegex("foundry-hosting/agent-framework-dotnet")] + private static partial System.Text.RegularExpressions.Regex SupplementRegex(); + [Fact] public async Task Polyfill_NonStreaming_PreservesAppId_ThroughCustomTransport_AddsSupplementAsync() { @@ -243,6 +246,57 @@ public async Task Polyfill_AncillaryProtocolMethod_AddsSupplementAsync(string me Assert.Contains("foundry-hosting/agent-framework-dotnet", req.UserAgent); } + [Fact] + public async Task Polyfill_RetryWithinCall_DoesNotDuplicateSupplementInUserAgentAsync() + { + // Arrange: a custom retry policy that re-runs the inner pipeline on the SAME message, + // so the per-call HostedAgentUserAgentPolicy fires multiple times against the same headers. + // The policy's Contains-guard must prevent the supplement from appearing twice. + var retryPolicy = new CountingRetryPolicy(extraAttempts: 2); + using var handler = new RecordingHandler(MinimalResponseJson()); +#pragma warning disable CA5399 + using var httpClient = new HttpClient(handler); +#pragma warning restore CA5399 + var inner = BuildInner(httpClient, userAgentApplicationId: "MY_APP_ID", retryPolicy: retryPolicy); + var chat = MakeWithDelegating(inner); + + // Act + _ = await chat.GetResponseAsync("hello"); + + // Assert: each retry attempt must have exactly ONE foundry-hosting segment, never two. + Assert.Equal(3, handler.Requests.Count); + foreach (var req in handler.Requests) + { + int matches = SupplementRegex().Matches(req.UserAgent).Count; + Assert.True(matches == 1, $"Expected exactly one foundry-hosting segment per retry attempt, got {matches}. UA: {req.UserAgent}"); + } + } + + [Fact] + public async Task TryApplyUserAgent_CalledTwiceOnSameAgent_DoesNotDoubleWrapAsync() + { + // Arrange: build a real ChatClientAgent whose IChatClient resolves to MEAI's + // OpenAIResponsesChatClient → ProjectResponsesClient (with a fake transport). + using var handler = new RecordingHandler(MinimalResponseJson()); +#pragma warning disable CA5399 + using var httpClient = new HttpClient(handler); +#pragma warning restore CA5399 + var inner = BuildInner(httpClient, userAgentApplicationId: "MY_APP_ID"); + IChatClient chatClient = inner.AsIChatClient(Deployment); + AIAgent agent = new ChatClientAgent(chatClient); + + // Act: apply twice. + FoundryHostingExtensions.TryApplyUserAgent(agent); + FoundryHostingExtensions.TryApplyUserAgent(agent); + + // Assert: invoking the agent produces exactly ONE outbound request whose UA contains + // the supplement EXACTLY ONCE (would be twice if the wrapper were nested). + _ = await chatClient.GetResponseAsync("hello"); + var req = Assert.Single(handler.Requests); + int matches = SupplementRegex().Matches(req.UserAgent).Count; + Assert.True(matches == 1, $"Expected exactly one foundry-hosting segment, got {matches}. UA: {req.UserAgent}"); + } + [Fact] public void OpenAIResponsesChatClient_ResponseClientField_ReflectionGuard() { diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/ServiceCollectionExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/ServiceCollectionExtensionsTests.cs index b443ab59ca..28235679e3 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/ServiceCollectionExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/ServiceCollectionExtensionsTests.cs @@ -96,6 +96,13 @@ public void ApplyOpenTelemetry_AlreadyInstrumentedAgent_ReturnsSameReference() Assert.Same(instrumented, result); } + [Fact] + public void TryApplyUserAgent_NullAgent_ThrowsArgumentNullException() + { + // Arrange + Act + Assert + Assert.Throws(() => FoundryHostingExtensions.TryApplyUserAgent(null!)); + } + [Fact] public void TryApplyUserAgent_AgentWithoutChatClient_NoOp() { @@ -126,16 +133,6 @@ public void TryApplyUserAgent_AgentWithNonMeaiChatClient_NoOp() Assert.Same(mockAgent.Object, result); } - [Fact] - public void TryApplyUserAgent_NullAgent_ReturnsNullWithoutThrowing() - { - // Arrange + Act - var result = FoundryHostingExtensions.TryApplyUserAgent(null!); - - // Assert - Assert.Null(result); - } - [Fact] public void MeaiOpenAIResponsesChatClient_TypeFullName_ReflectionGuard() { From d3b9cfad9b8474776736a760f252323d53fce244 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> Date: Thu, 30 Apr 2026 13:44:13 +0100 Subject: [PATCH 6/7] .NET: Drop null check from TryApplyUserAgent and its now-redundant test The two call sites in AgentFrameworkResponseHandler.GetAgent already null-check the agent before invoking TryApplyUserAgent, so the defensive ArgumentNullException is unreachable. Remove it and the corresponding test. --- .../ServiceCollectionExtensions.cs | 3 +-- .../Hosting/ServiceCollectionExtensionsTests.cs | 7 ------- 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ServiceCollectionExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ServiceCollectionExtensions.cs index 6c7b15234d..b77ba1d3d4 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ServiceCollectionExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ServiceCollectionExtensions.cs @@ -11,6 +11,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; using OpenAI.Responses; namespace Microsoft.Agents.AI.Foundry.Hosting; @@ -236,8 +237,6 @@ internal static AIAgent ApplyOpenTelemetry(AIAgent agent) /// internal static AIAgent TryApplyUserAgent(AIAgent agent) { - ArgumentNullException.ThrowIfNull(agent); - var chatClient = agent.GetService(); if (chatClient is null) { diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/ServiceCollectionExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/ServiceCollectionExtensionsTests.cs index 28235679e3..c5b9bf1701 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/ServiceCollectionExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/ServiceCollectionExtensionsTests.cs @@ -96,13 +96,6 @@ public void ApplyOpenTelemetry_AlreadyInstrumentedAgent_ReturnsSameReference() Assert.Same(instrumented, result); } - [Fact] - public void TryApplyUserAgent_NullAgent_ThrowsArgumentNullException() - { - // Arrange + Act + Assert - Assert.Throws(() => FoundryHostingExtensions.TryApplyUserAgent(null!)); - } - [Fact] public void TryApplyUserAgent_AgentWithoutChatClient_NoOp() { From c64ba5f5ad099899fda900d2354dfbc48abe5e61 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> Date: Thu, 30 Apr 2026 13:47:55 +0100 Subject: [PATCH 7/7] .NET: Remove unused Microsoft.Shared.Diagnostics import in ServiceCollectionExtensions The Throw.IfNull helper from this namespace was used by the now-removed null check in TryApplyUserAgent. Drop the unused import to satisfy IDE0005 in CI's full-project dotnet format run. --- .../ServiceCollectionExtensions.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ServiceCollectionExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ServiceCollectionExtensions.cs index b77ba1d3d4..49eb745b6b 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ServiceCollectionExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ServiceCollectionExtensions.cs @@ -11,7 +11,6 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Shared.DiagnosticIds; -using Microsoft.Shared.Diagnostics; using OpenAI.Responses; namespace Microsoft.Agents.AI.Foundry.Hosting;