mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
b12109b7e4
* 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.
252 lines
11 KiB
C#
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<AIAgent>("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();
|
|
}
|