diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx
index b9a5240fc9..2bfb78486c 100644
--- a/dotnet/agent-framework-dotnet.slnx
+++ b/dotnet/agent-framework-dotnet.slnx
@@ -163,10 +163,10 @@
-
+
@@ -226,6 +226,7 @@
+
@@ -347,17 +348,17 @@
-
+
+
-
@@ -543,8 +544,8 @@
-
+
diff --git a/dotnet/samples/03-workflows/Declarative/InvokeHttpRequest/InvokeHttpRequest.csproj b/dotnet/samples/03-workflows/Declarative/InvokeHttpRequest/InvokeHttpRequest.csproj
new file mode 100644
index 0000000000..afc2e0afab
--- /dev/null
+++ b/dotnet/samples/03-workflows/Declarative/InvokeHttpRequest/InvokeHttpRequest.csproj
@@ -0,0 +1,38 @@
+
+
+
+ Exe
+ net10.0
+ enable
+ enable
+
+
+
+ true
+ true
+ true
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Always
+
+
+
+
diff --git a/dotnet/samples/03-workflows/Declarative/InvokeHttpRequest/InvokeHttpRequest.yaml b/dotnet/samples/03-workflows/Declarative/InvokeHttpRequest/InvokeHttpRequest.yaml
new file mode 100644
index 0000000000..b903bb92ea
--- /dev/null
+++ b/dotnet/samples/03-workflows/Declarative/InvokeHttpRequest/InvokeHttpRequest.yaml
@@ -0,0 +1,76 @@
+#
+# This workflow demonstrates using HttpRequestAction to call a REST API directly
+# from the workflow without going through an AI agent first.
+#
+# HttpRequestAction allows workflows to:
+# - Fetch data from external HTTP endpoints
+# - Store the parsed response in workflow variables for later use
+# - Add the response body to the conversation so a downstream agent can
+# answer questions based on it
+#
+# This sample fetches public metadata for the dotnet/runtime repository from
+# the GitHub REST API (no authentication required) and uses an agent to
+# answer follow-up questions about it.
+#
+# Example input:
+# How many subscribers does the repository have?
+#
+kind: Workflow
+trigger:
+
+ kind: OnConversationStart
+ id: workflow_invoke_http_request_demo
+ actions:
+
+ # Capture the original user message for input to the follow-up agent.
+ - kind: SetVariable
+ id: set_user_message
+ variable: Local.InputMessage
+ value: =System.LastMessage
+
+ # Set the repository org/name used to form the request URL.
+ - kind: SetVariable
+ id: set_repo_name
+ variable: Local.RepoName
+ value: microsoft/agent-framework
+
+ # Invoke the GitHub repo API. The response body is parsed into Local.RepoInfo
+ # and also added to the conversation (via conversationId) so the agent below
+ # can answer questions based on it.
+ - kind: HttpRequestAction
+ id: fetch_repo_info
+ conversationId: =System.ConversationId
+ method: GET
+ url: =Concatenate("https://api.github.com/repos/", Local.RepoName)
+ headers:
+ Accept: application/vnd.github+json
+ User-Agent: agent-framework-sample
+ response: Local.RepoInfo
+
+ # Display a confirmation message showing key fields from the parsed response.
+ - kind: SendMessage
+ id: show_repo_summary
+ message: "Fetched repo: visibility={Local.RepoInfo.visibility}, description={Local.RepoInfo.description}"
+
+ # Use the agent to summarize the repo using the conversation context.
+ - kind: InvokeAzureAgent
+ id: summarize_repo
+ conversationId: =System.ConversationId
+ agent:
+ name: GitHubRepoInfoAgent
+ input:
+ messages: =UserMessage("Please provide a brief summary of this GitHub repository based on the data already in the conversation.")
+ output:
+ autoSend: true
+ messages: Local.AgentResponse
+
+ # Allow the user to ask follow-up questions about the repo in a loop.
+ - kind: InvokeAzureAgent
+ id: invoke_followup
+ conversationId: =System.ConversationId
+ agent:
+ name: GitHubRepoInfoAgent
+ input:
+ messages: =Local.InputMessage
+ externalLoop:
+ when: =Upper(System.LastMessage.Text) <> "EXIT"
diff --git a/dotnet/samples/03-workflows/Declarative/InvokeHttpRequest/Program.cs b/dotnet/samples/03-workflows/Declarative/InvokeHttpRequest/Program.cs
new file mode 100644
index 0000000000..a27226847e
--- /dev/null
+++ b/dotnet/samples/03-workflows/Declarative/InvokeHttpRequest/Program.cs
@@ -0,0 +1,95 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using Azure.AI.Projects;
+using Azure.AI.Projects.Agents;
+using Azure.Identity;
+using Microsoft.Agents.AI.Workflows.Declarative;
+using Microsoft.Extensions.Configuration;
+using Shared.Foundry;
+using Shared.Workflows;
+
+namespace Demo.Workflows.Declarative.InvokeHttpRequest;
+
+///
+/// Demonstrates a workflow that uses HttpRequestAction to call a REST API
+/// directly from the workflow.
+///
+///
+///
+/// The HttpRequestAction allows workflows to issue HTTP requests and:
+///
+///
+/// - Fetch data from external REST endpoints
+/// - Store the parsed response in workflow variables
+/// - Add the response body to the conversation so an agent can answer
+/// questions based on it
+///
+///
+/// This sample fetches public metadata for the dotnet/runtime repository from
+/// the GitHub REST API (no authentication required) and uses a Foundry agent
+/// to answer follow-up questions about it. Type "EXIT" to end the conversation.
+///
+///
+/// See the README.md file in the parent folder (../README.md) for detailed
+/// information about the configuration required to run this sample.
+///
+///
+internal sealed class Program
+{
+ public static async Task Main(string[] args)
+ {
+ // Initialize configuration
+ IConfiguration configuration = Application.InitializeConfig();
+ Uri foundryEndpoint = new(configuration.GetValue(Application.Settings.FoundryEndpoint));
+
+ // Ensure sample agent exists in Foundry. The agent has no tools - it answers
+ // questions about the GitHub repository using only the JSON data that the
+ // HttpRequestAction adds to the conversation.
+ await CreateAgentAsync(foundryEndpoint, configuration);
+
+ // Get input from command line or console
+ string workflowInput = Application.GetInput(args);
+
+ // The default HttpRequestHandler is sufficient for this sample because the
+ // GitHub REST endpoint used here does not require authentication. For
+ // authenticated endpoints, supply a custom Func
+ // to DefaultHttpRequestHandler so each request can be routed through a
+ // pre-configured (cached) HttpClient with the appropriate credentials.
+ await using DefaultHttpRequestHandler httpRequestHandler = new();
+
+ // Create the workflow factory with the HTTP request handler
+ WorkflowFactory workflowFactory = new("InvokeHttpRequest.yaml", foundryEndpoint)
+ {
+ HttpRequestHandler = httpRequestHandler
+ };
+
+ // Execute the workflow
+ WorkflowRunner runner = new() { UseJsonCheckpoints = true };
+ await runner.ExecuteAsync(workflowFactory.CreateWorkflow, workflowInput);
+ }
+
+ private static async Task CreateAgentAsync(Uri foundryEndpoint, IConfiguration configuration)
+ {
+ // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
+ AIProjectClient aiProjectClient = new(foundryEndpoint, new DefaultAzureCredential());
+
+ await aiProjectClient.CreateAgentAsync(
+ agentName: "GitHubRepoInfoAgent",
+ agentDefinition: DefineAgent(configuration),
+ agentDescription: "Answers questions about a GitHub repository using HTTP response data in the conversation");
+ }
+
+ private static DeclarativeAgentDefinition DefineAgent(IConfiguration configuration)
+ {
+ return new DeclarativeAgentDefinition(configuration.GetValue(Application.Settings.FoundryModel))
+ {
+ Instructions =
+ """
+ Answer the user's questions about the GitHub repository using only the
+ JSON data already present in the conversation history.
+ If the answer is not contained in the conversation, say so plainly
+ rather than guessing. Be concise and helpful.
+ """
+ };
+ }
+}
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 @@ public class AgentFrameworkResponseHandler : ResponseHandler
var agent = this._serviceProvider.GetKeyedService(agentName);
if (agent is not null)
{
+ FoundryHostingExtensions.TryApplyUserAgent(agent);
return FoundryHostingExtensions.ApplyOpenTelemetry(agent);
}
@@ -310,12 +311,13 @@ public class AgentFrameworkResponseHandler : ResponseHandler
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 @@ public class AgentFrameworkResponseHandler : ResponseHandler
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 dda822ef66..49eb745b6b 100644
--- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ServiceCollectionExtensions.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ServiceCollectionExtensions.cs
@@ -3,16 +3,15 @@
using System;
using System.Diagnostics.CodeAnalysis;
using System.Reflection;
-using System.Threading.Tasks;
using Azure.AI.AgentServer.Responses;
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;
@@ -36,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();
@@ -181,13 +180,6 @@ public static class FoundryHostingExtensions
{
ArgumentNullException.ThrowIfNull(endpoints);
endpoints.MapResponsesServer(prefix);
-
- if (endpoints is IApplicationBuilder app)
- {
- // Ensure the middleware is added to the pipeline
- app.UseMiddleware();
- }
-
return endpoints;
}
@@ -216,46 +208,85 @@ public static class FoundryHostingExtensions
.Build();
}
- private sealed class AgentFrameworkUserAgentMiddleware(RequestDelegate next)
+ ///
+ /// Attempts to wrap the agent's underlying
+ /// with a so every outgoing Responses-API request
+ /// carries the hosted-agent User-Agent segment.
+ ///
+ ///
+ ///
+ /// 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)
{
- private static readonly string s_userAgentValue = CreateUserAgentValue();
-
- public async Task InvokeAsync(HttpContext context)
+ var chatClient = agent.GetService();
+ if (chatClient 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 meaiType = s_meaiResponsesChatClientType;
+ if (meaiType is null)
{
- const string Name = "agent-framework-dotnet";
-
- if (typeof(AgentFrameworkUserAgentMiddleware).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;
+ return agent;
}
+
+ var meaiInstance = chatClient.GetService(meaiType);
+ if (meaiInstance is null)
+ {
+ return agent;
+ }
+
+ var field = s_meaiResponseClientField;
+ if (field is null)
+ {
+ return agent;
+ }
+
+ var current = field.GetValue(meaiInstance) as ResponsesClient;
+ if (current is null or DelegatingResponsesClient)
+ {
+ return agent;
+ }
+
+ field.SetValue(meaiInstance, new DelegatingResponsesClient(current));
+ return agent;
}
+
+ ///
+ /// 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");
+
+ ///
+ /// 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/RequestOptionsExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/RequestOptionsExtensions.cs
index 03e48e293b..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
{
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/ChatMessageExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/ChatMessageExtensions.cs
index 75a87fb8ee..47b4efc5c7 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/ChatMessageExtensions.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/ChatMessageExtensions.cs
@@ -16,6 +16,60 @@ internal static class ChatMessageExtensions
public static RecordValue ToRecord(this ChatMessage message) =>
FormulaValue.NewRecordFromFields(message.GetMessageFields());
+ ///
+ /// Merges the user-authored with the round-tripped
+ /// returned by AgentProvider.CreateMessageAsync
+ /// to produce the value stored in System.LastMessage.
+ ///
+ ///
+ /// The agent service often strips or alters on round-trip,
+ /// while replacing inline media (, )
+ /// with server-side references (typically ).
+ /// We want both: the original text (so =System.LastMessage.Text works) and
+ /// the server's media references (so subsequent actions don't re-upload large blobs).
+ ///
+ /// Strategy: keep as the base — it has the server-generated
+ /// and any provider-augmented metadata, and is forward-
+ /// compatible with new properties added on in the abstractions
+ /// layer. Only the list is mutated to substitute
+ /// original items in place (and append any extras the round-trip
+ /// dropped). Non-text content items returned by the service are left untouched so
+ /// server-side references survive.
+ ///
+ ///
+ public static ChatMessage MergeForLastMessage(this ChatMessage input, ChatMessage? inputMessage)
+ {
+ if (inputMessage is null)
+ {
+ return input;
+ }
+
+ // Build a queue of the original text items, in order. Fall back to ChatMessage.Text
+ // if the input has no explicit TextContent entries.
+ Queue originalTexts = new(input.Contents.OfType());
+ if (originalTexts.Count == 0 && !string.IsNullOrEmpty(input.Text))
+ {
+ originalTexts.Enqueue(new TextContent(input.Text));
+ }
+
+ // Replace TextContent items in inputMessage.Contents with the originals, in order.
+ for (int i = 0; i < inputMessage.Contents.Count && originalTexts.Count > 0; i++)
+ {
+ if (inputMessage.Contents[i] is TextContent)
+ {
+ inputMessage.Contents[i] = originalTexts.Dequeue();
+ }
+ }
+
+ // Append any remaining original text items that the round-trip dropped entirely.
+ while (originalTexts.Count > 0)
+ {
+ inputMessage.Contents.Add(originalTexts.Dequeue());
+ }
+
+ return inputMessage;
+ }
+
public static TableValue ToTable(this IEnumerable messages) =>
FormulaValue.NewTable(TypeSchema.Message.RecordType, messages.Select(message => message.ToRecord()));
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/DeclarativeWorkflowExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/DeclarativeWorkflowExecutor.cs
index 9c6f7f3e6f..52f20a859a 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/DeclarativeWorkflowExecutor.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/DeclarativeWorkflowExecutor.cs
@@ -43,7 +43,11 @@ internal sealed class DeclarativeWorkflowExecutor(
await declarativeContext.QueueConversationUpdateAsync(conversationId, isExternal: true, cancellationToken).ConfigureAwait(false);
ChatMessage inputMessage = await options.AgentProvider.CreateMessageAsync(conversationId, input, cancellationToken).ConfigureAwait(false);
- await declarativeContext.SetLastMessageAsync(inputMessage).ConfigureAwait(false);
+
+ // Use the original input for System.LastMessage to ensure Text is preserved (the
+ // service may strip text on round-trip), but substitute server-side media references
+ // (e.g., HostedFileContent) so subsequent actions don't re-upload large blobs.
+ await declarativeContext.SetLastMessageAsync(input.MergeForLastMessage(inputMessage)).ConfigureAwait(false);
await context.SendResultMessageAsync(this.Id, cancellationToken).ConfigureAwait(false);
}
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Kit/RootExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Kit/RootExecutor.cs
index ff643510df..80f6e69b60 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Kit/RootExecutor.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Kit/RootExecutor.cs
@@ -58,7 +58,6 @@ public abstract class RootExecutor : Executor, IResettableExecut
public override async ValueTask HandleAsync(TInput message, IWorkflowContext context, CancellationToken cancellationToken)
{
DeclarativeWorkflowContext declarativeContext = new(context, this._state);
- await this.ExecuteAsync(message, declarativeContext, cancellationToken).ConfigureAwait(false);
ChatMessage input = (this._inputTransform ?? DefaultInputTransform).Invoke(message);
@@ -69,7 +68,13 @@ public abstract class RootExecutor : Executor, IResettableExecut
await declarativeContext.QueueConversationUpdateAsync(this._conversationId, isExternal: true, cancellationToken).ConfigureAwait(false);
ChatMessage inputMessage = await this._agentProvider.CreateMessageAsync(this._conversationId, input, cancellationToken).ConfigureAwait(false);
- await declarativeContext.SetLastMessageAsync(inputMessage).ConfigureAwait(false);
+
+ // Use the original input for System.LastMessage to ensure Text is preserved (the
+ // service may strip text on round-trip), but substitute server-side media references
+ // (e.g., HostedFileContent) so subsequent actions don't re-upload large blobs.
+ await declarativeContext.SetLastMessageAsync(input.MergeForLastMessage(inputMessage)).ConfigureAwait(false);
+
+ await this.ExecuteAsync(message, declarativeContext, cancellationToken).ConfigureAwait(false);
await declarativeContext.SendResultMessageAsync(this.Id, cancellationToken).ConfigureAwait(false);
}
diff --git a/dotnet/src/Shared/Workflows/Execution/WorkflowFactory.cs b/dotnet/src/Shared/Workflows/Execution/WorkflowFactory.cs
index a36c388e73..68e4af2878 100644
--- a/dotnet/src/Shared/Workflows/Execution/WorkflowFactory.cs
+++ b/dotnet/src/Shared/Workflows/Execution/WorkflowFactory.cs
@@ -25,6 +25,9 @@ internal sealed class WorkflowFactory(string workflowFile, Uri foundryEndpoint)
// Assign to provide MCP tool capabilities
public IMcpToolHandler? McpToolHandler { get; init; }
+ // Assign to enable HttpRequestAction support
+ public IHttpRequestHandler? HttpRequestHandler { get; init; }
+
///
/// Create the workflow from the declarative YAML. Includes definition of the
/// and the associated .
@@ -46,6 +49,7 @@ internal sealed class WorkflowFactory(string workflowFile, Uri foundryEndpoint)
ConversationId = this.ConversationId,
LoggerFactory = this.LoggerFactory,
McpToolHandler = this.McpToolHandler,
+ HttpRequestHandler = this.HttpRequestHandler,
};
string workflowPath = Path.Combine(AppContext.BaseDirectory, workflowFile);
diff --git a/dotnet/src/Shared/Workflows/Execution/WorkflowRunner.cs b/dotnet/src/Shared/Workflows/Execution/WorkflowRunner.cs
index 8135c25570..0d2e30ceeb 100644
--- a/dotnet/src/Shared/Workflows/Execution/WorkflowRunner.cs
+++ b/dotnet/src/Shared/Workflows/Execution/WorkflowRunner.cs
@@ -162,7 +162,10 @@ internal sealed class WorkflowRunner
case RequestInfoEvent requestInfo:
Debug.WriteLine($"REQUEST #{requestInfo.Request.RequestId}");
- externalResponse = requestInfo.Request;
+ if (response is null || !string.Equals(requestInfo.Request.RequestId, response.RequestId, StringComparison.Ordinal))
+ {
+ externalResponse = requestInfo.Request;
+ }
break;
case ConversationUpdateEvent invokeEvent:
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..8ec8a7f570
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/DelegatingResponsesClientTests.cs
@@ -0,0 +1,453 @@
+// 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 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()
+ {
+ // 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 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()
+ {
+ // 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..c5b9bf1701 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;
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,45 @@ public class ServiceCollectionExtensionsTests
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