Files
agent-framework/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ServiceCollectionExtensions.cs
T
Roger Barreto b12109b7e4 .NET: Bump MEAI to 10.5.1 and add Foundry per-call x-client header support (#5652)
* Bump MEAI to 10.5.1 and add per-call x-client header support

Replaces the brittle UserAgentResponsesClient subclass with a clean
per-call x-client-* header pipeline built on the new Microsoft.Extensions.AI
10.5.1 OpenAIRequestPolicies hook.

Public surface (Microsoft.Agents.AI.Foundry, [Experimental(MAAI001)]):
* chatOptions.WithClientHeader(name, value) and .WithClientHeaders(IEnumerable)
  validate the x-client- prefix (case-insensitive), apply all-or-nothing on
  bulk, and throw InvalidOperationException on foreign-typed slot collision
* myAgent.AsBuilder().UseClientHeaders().Build() opts a customer-built agent
  into the pipeline; idempotent via agent.GetService<ClientHeadersAgent>()
* Foundry-built agents (FoundryAgent.Create*) pre-wire automatically

Internals:
* ClientHeadersAgent decorator snapshots the dict at scope-push time so
  concurrent runs sharing a ChatOptions reference do not leak headers
* ClientHeadersScope is an AsyncLocal<IReadOnlyDictionary<string,string>?>
  with LIFO push/dispose semantics
* ClientHeadersPolicy singleton stamps headers via Headers.Set so per-call
  values overwrite any same-name header from earlier policies and so
  duplicate registration is value-stable
* OpenAIRequestPoliciesReflection dedups against MEAI's private _entries
  field and falls back to AddPolicy on any reflection failure; a CI test
  asserts the field shape on every MEAI bump

Hosting cleanup:
* Deleted UserAgentResponsesClient and its dummy throwing pipeline
* HostedAgentUserAgentPolicy is now registered via OpenAIRequestPolicies
  in FoundryHostingExtensions.TryApplyUserAgent

Tests:
* 19 new unit tests in ClientHeadersExtensionsTests.cs covering validation,
  AsyncLocal isolation, snapshot semantics, end-to-end wire stamping, and
  shared-chat-client dedup
* Updated OpenTelemetryAgentTests for MEAI 10.5.1 changes to web_search
  serialization and the reduced tool definition payload when sensitive
  data capture is disabled

Microsoft.Extensions.Compliance.Abstractions stays at 10.5.0 because no
10.5.1 release exists on nuget.org.

* Address PR review: pre-wire AsAIAgent path and dedup TryApplyUserAgent

* FoundryAgent: extract WireClientHeaders helper and call it from the
  internal (AIProjectClient, ChatClientAgent) constructor used by
  AzureAIProjectChatClientExtensions.AsAIAgent so those Foundry-built
  agents also pre-wire the x-client header pipeline.
* Foundry.Hosting TryApplyUserAgent: dedup HostedAgentUserAgentPolicy
  registration per OpenAIRequestPolicies instance via
  ConditionalWeakTable so per-request resolution does not grow the
  policy list unboundedly on singleton agents.

* Add tests covering AsAIAgent pre-wire and TryApplyUserAgent dedup

Backs the PR review fixes from a4c8f91 with regression tests:
* ClientHeadersExtensionsTests: AsAIAgent_FoundryAgent_HasPreWiredClientHeadersAgent
  asserts the FoundryAgent built via AzureAIProjectChatClientExtensions.AsAIAgent
  contains a ClientHeadersAgent in its delegating chain (catches future
  regressions of the bypass).
* ClientHeadersExtensionsTests: FoundryAgent_PublicConstructor_HasPreWiredClientHeadersAgent
  covers the public constructor path the same way.
* ClientHeadersExtensionsTests: UseClientHeaders_RepeatedRegistrations_OnSameChatClient_OnlyRegistersOnce
  invokes UseClientHeaders 25 times on a shared chat client and asserts via
  reflection that OpenAIRequestPolicies._entries length is exactly 1.
* HostedTryApplyUserAgentDedupTests: two tests asserting
  FoundryHostingExtensions.TryApplyUserAgent stays at one entry per
  OpenAIRequestPolicies instance after 50 calls on the same agent and across
  distinct agents on different chat clients.

* Move tests next to their SUT

Removes the dedicated HostedTryApplyUserAgentDedupTests.cs test class.
Tests are co-located with the SUT they exercise:

* FoundryAgentTests.cs gains the Constructor_PreWiresClientHeadersAgent
  and Constructor_FromAsAIAgentExtension_PreWiresClientHeadersAgent
  cases, since FoundryAgent is the SUT for the pre-wire behavior.
* HostedOutboundUserAgentTests.cs gains the two TryApplyUserAgent dedup
  cases, since FoundryHostingExtensions.TryApplyUserAgent is the SUT
  it already covers.
* ClientHeadersExtensionsTests.cs keeps only the
  UseClientHeaders_RepeatedRegistrations_OnSameChatClient_OnlyRegistersOnce
  case, which exercises the public ClientHeadersExtensions surface.

* Remove redundant WithCancellation on inner streaming call

ct is already passed to InnerAgent.RunStreamingAsync, so
.WithCancellation(ct) on the resulting IAsyncEnumerable is a no-op.
Caught by Sergey on PR review.

* Address PR review: surface downstream MEAI experimental ID

* Add AIOpenAIRequestPolicies = MEAIExperiments alias to
  DiagnosticIds.Experiments (matches the existing AIResponseContinuations,
  AIMcpServers, AIFunctionApprovals pattern).
* Mark public ClientHeadersExtensions with [Experimental(AIOpenAIRequestPolicies)]
  instead of AgentsAIExperiments. Consumers now see the MEAI001 warning,
  surfacing the dependency on MEAI's experimental OpenAIRequestPolicies hook.
* Mark internal OpenAIRequestPoliciesReflection with the same alias to
  suppress warnings at the source rather than via project-wide NoWarn.
* Remove MEAI001 from Foundry csproj NoWarn (kept on Foundry.Hosting where
  pre-PR usages remain).
* Clarify ClientHeadersScope XML doc: AsyncLocal flows values forward but
  does NOT auto-restore on method return; explicit using/Dispose is what
  gives stack-style LIFO semantics.
2026-05-06 14:43:08 +00:00

252 lines
11 KiB
C#

// Copyright (c) Microsoft. All rights reserved.
using System;
using System.ClientModel.Primitives;
using System.Diagnostics.CodeAnalysis;
using System.Runtime.CompilerServices;
using Azure.AI.AgentServer.Responses;
using Azure.Core;
using Azure.Identity;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI.Foundry.Hosting;
/// <summary>
/// Extension methods for registering agent-framework agents as Foundry Hosted Agents
/// using the Azure AI Responses Server SDK.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
public static class FoundryHostingExtensions
{
/// <summary>
/// Registers the Azure AI Responses Server SDK and <see cref="AgentFrameworkResponseHandler"/>
/// as the <see cref="ResponseHandler"/>. Agents are resolved from keyed DI services
/// using the <c>agent.name</c> or <c>metadata["entity_id"]</c> from incoming requests.
/// </summary>
/// <remarks>
/// <para>
/// This method calls <c>AddResponsesServer()</c> internally, so you do not need to
/// call it separately. Register your <see cref="AIAgent"/> instances before calling this.
/// </para>
/// <para>
/// Example:
/// <code>
/// builder.Services.AddKeyedSingleton&lt;AIAgent&gt;("my-agent", myAgent);
/// builder.Services.AddFoundryResponses();
///
/// var app = builder.Build();
/// app.MapFoundryResponses();
/// </code>
/// </para>
/// </remarks>
/// <param name="services">The service collection.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddFoundryResponses(this IServiceCollection services)
{
ArgumentNullException.ThrowIfNull(services);
services.AddResponsesServer();
services.TryAddSingleton<AgentSessionStore>(_ => FileSystemAgentSessionStore.CreateDefault());
services.TryAddSingleton<ResponseHandler, AgentFrameworkResponseHandler>();
return services;
}
/// <summary>
/// Registers the Azure AI Responses Server SDK and a specific <see cref="AIAgent"/>
/// as the handler for all incoming requests, regardless of the <c>agent.name</c> in the request.
/// </summary>
/// <remarks>
/// <para>
/// Use this overload when hosting a single agent. The provided agent instance is
/// registered as both a keyed service and the default <see cref="AIAgent"/>.
/// This method calls <c>AddResponsesServer()</c> internally.
/// </para>
/// <para>
/// Example:
/// <code>
/// builder.Services.AddFoundryResponses(myAgent);
///
/// var app = builder.Build();
/// app.MapFoundryResponses();
/// </code>
/// </para>
/// </remarks>
/// <param name="services">The service collection.</param>
/// <param name="agent">The agent instance to register.</param>
/// <param name="agentSessionStore">The agent session store to use for managing agent sessions server-side. If null, a file-system session store is used, rooted at <c>/.checkpoints</c> when running in a Foundry hosted environment and <c>{cwd}/.checkpoints</c> locally.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddFoundryResponses(this IServiceCollection services, AIAgent agent, AgentSessionStore? agentSessionStore = null)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(agent);
services.AddResponsesServer();
agentSessionStore ??= FileSystemAgentSessionStore.CreateDefault();
if (!string.IsNullOrWhiteSpace(agent.Name))
{
services.TryAddKeyedSingleton(agent.Name, agent);
services.TryAddKeyedSingleton(agent.Name, agentSessionStore);
}
// Also register as the default (non-keyed) agent so requests
// without an agent name can resolve it (e.g., local dev tooling).
services.TryAddSingleton(agent);
services.TryAddSingleton(agentSessionStore);
services.TryAddSingleton<ResponseHandler, AgentFrameworkResponseHandler>();
return services;
}
/// <summary>
/// Registers the Foundry Toolbox service, which eagerly connects to the Foundry Toolboxes
/// MCP proxy at startup and provides MCP tools to <see cref="AgentFrameworkResponseHandler"/>.
/// </summary>
/// <remarks>
/// <para>
/// Each string in <paramref name="toolboxNames"/> is a toolbox name registered in the Foundry
/// project. The proxy URL per toolbox is constructed as:
/// <c>{FOUNDRY_AGENT_TOOLSET_ENDPOINT}/{toolboxName}/mcp?api-version=2025-05-01-preview</c>
/// </para>
/// <para>
/// When <c>FOUNDRY_AGENT_TOOLSET_ENDPOINT</c> is absent, startup succeeds without error and
/// no tools are loaded (the container remains healthy per spec ยง2).
/// </para>
/// <para>
/// Example:
/// <code>
/// builder.Services.AddFoundryToolboxes("my-toolbox", "another-toolbox");
/// </code>
/// </para>
/// </remarks>
/// <param name="services">The service collection.</param>
/// <param name="toolboxNames">Names of the Foundry toolboxes to connect to.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddFoundryToolboxes(
this IServiceCollection services,
params string[] toolboxNames)
=> services.AddFoundryToolboxes(configureOptions: null, toolboxNames);
/// <summary>
/// Registers the Foundry Toolbox service with additional options configuration.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="configureOptions">Callback to further configure <see cref="FoundryToolboxOptions"/> (e.g. set <see cref="FoundryToolboxOptions.StrictMode"/>).</param>
/// <param name="toolboxNames">Names of the Foundry toolboxes to pre-register at startup.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddFoundryToolboxes(
this IServiceCollection services,
Action<FoundryToolboxOptions>? configureOptions,
params string[] toolboxNames)
{
ArgumentNullException.ThrowIfNull(services);
services.Configure<FoundryToolboxOptions>(opt =>
{
foreach (var name in toolboxNames)
{
if (!string.IsNullOrWhiteSpace(name))
{
opt.ToolboxNames.Add(name);
}
}
configureOptions?.Invoke(opt);
});
// Register DefaultAzureCredential as the default TokenCredential if not already registered
services.TryAddSingleton<TokenCredential>(_ => new DefaultAzureCredential());
// Register FoundryToolboxService as a singleton so it can be injected into the handler
services.TryAddSingleton<FoundryToolboxService>();
// AddHostedService uses TryAddEnumerable internally, so calling AddFoundryToolboxes
// multiple times will not invoke StartAsync twice on the same singleton.
services.AddHostedService(sp => sp.GetRequiredService<FoundryToolboxService>());
return services;
}
/// <summary>
/// Maps the Responses API routes for the agent-framework handler to the endpoint routing pipeline.
/// </summary>
/// <param name="endpoints">The endpoint route builder.</param>
/// <param name="prefix">Optional route prefix (e.g., "/openai/v1"). Default: empty (routes at /responses).</param>
/// <returns>The endpoint route builder for chaining.</returns>
public static IEndpointRouteBuilder MapFoundryResponses(this IEndpointRouteBuilder endpoints, string prefix = "")
{
ArgumentNullException.ThrowIfNull(endpoints);
endpoints.MapResponsesServer(prefix);
return endpoints;
}
/// <summary>
/// The ActivitySource name for the Responses hosting pipeline.
/// </summary>
private const string ResponsesSourceName = "Azure.AI.AgentServer.Responses";
/// <summary>
/// Wraps <paramref name="agent"/> with <see cref="OpenTelemetryAgent"/> instrumentation
/// so that agent invocations emit spans into the pipeline registered by
/// <c>Azure.AI.AgentServer.Core</c>'s <c>AddAgentHostTelemetry()</c>.
/// If the agent is already instrumented the original instance is returned unchanged.
/// </summary>
internal static AIAgent ApplyOpenTelemetry(AIAgent agent)
{
if (agent.GetService<OpenTelemetryAgent>() is not null)
{
return agent;
}
return agent.AsBuilder()
.UseOpenTelemetry(sourceName: ResponsesSourceName)
.Build();
}
/// <summary>
/// Registers the hosted-agent <c>User-Agent</c> supplement policy
/// (<see cref="HostedAgentUserAgentPolicy"/>) on the agent's underlying chat client via the
/// MEAI 10.5.1 <see cref="OpenAIRequestPolicies"/> hook so every outgoing OpenAI Responses
/// request carries the segment <c>foundry-hosting/agent-framework-dotnet/{version}</c>.
/// </summary>
/// <remarks>
/// <para>
/// Best-effort and idempotent. The method is a no-op when:
/// <list type="bullet">
/// <item><description><paramref name="agent"/> exposes no <see cref="IChatClient"/>;</description></item>
/// <item><description>the chat client is not OpenAI-backed (the <see cref="OpenAIRequestPolicies"/> service lookup returns <see langword="null"/>);</description></item>
/// <item><description>the policy was already registered on this client by a prior invocation (deduped via reflection on <c>OpenAIRequestPolicies._entries</c>).</description></item>
/// </list>
/// </para>
/// <para>
/// Returns the same <paramref name="agent"/> instance unchanged. The policy is installed
/// on the chat client; the agent itself is not wrapped.
/// </para>
/// </remarks>
internal static AIAgent TryApplyUserAgent(AIAgent agent)
{
var chatClient = agent.GetService<IChatClient>();
if (chatClient?.GetService<OpenAIRequestPolicies>() is { } policies)
{
// Hosted agents are typically singletons resolved per request, so AddPolicy must be
// called at most once per OpenAIRequestPolicies instance to avoid unbounded growth of
// the policy list (each entry adds per-request CPU work even though the User-Agent
// value stays stable). Track which instances we have already wired with a
// ConditionalWeakTable keyed on the OpenAIRequestPolicies reference; the table holds
// weak references so it does not extend the lifetime of the chat client.
if (s_userAgentRegistrations.TryAdd(policies, s_boxedTrue))
{
policies.AddPolicy(HostedAgentUserAgentPolicy.Instance, PipelinePosition.PerCall);
}
}
return agent;
}
private static readonly object s_boxedTrue = new();
private static readonly ConditionalWeakTable<OpenAIRequestPolicies, object> s_userAgentRegistrations = new();
}