mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
* 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.
104 lines
3.9 KiB
C#
104 lines
3.9 KiB
C#
// Copyright (c) Microsoft. All rights reserved.
|
|
|
|
using System.Collections.Generic;
|
|
using System.Runtime.CompilerServices;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Microsoft.Extensions.AI;
|
|
|
|
namespace Microsoft.Agents.AI.Foundry;
|
|
|
|
/// <summary>
|
|
/// Delegating <see cref="AIAgent"/> that captures any <c>x-client-*</c> headers stored on
|
|
/// <see cref="ChatClientAgentRunOptions.ChatOptions"/> by callers of
|
|
/// <see cref="ClientHeadersExtensions.WithClientHeader(ChatOptions, string, string)"/> and pushes
|
|
/// them onto a <see cref="ClientHeadersScope"/> for the lifetime of the run. The scope is read by
|
|
/// <see cref="ClientHeadersPolicy"/> inside the SCM transport pipeline and stamped onto the
|
|
/// outbound request.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The decorator snapshots the header dictionary at scope-push time so concurrent runs that share
|
|
/// the same <see cref="ChatOptions"/> reference are isolated; mutating the source dictionary after
|
|
/// <c>RunAsync</c> begins does not leak into in-flight requests.
|
|
/// </para>
|
|
/// <para>
|
|
/// Streaming uses the async-iterator pattern so the AsyncLocal scope stays alive across yields,
|
|
/// which is required because the underlying HTTP send happens during enumeration.
|
|
/// </para>
|
|
/// </remarks>
|
|
internal sealed class ClientHeadersAgent : DelegatingAIAgent
|
|
{
|
|
public ClientHeadersAgent(AIAgent innerAgent)
|
|
: base(innerAgent)
|
|
{
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
protected override Task<AgentResponse> RunCoreAsync(
|
|
IEnumerable<ChatMessage> messages,
|
|
AgentSession? session = null,
|
|
AgentRunOptions? options = null,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var snapshot = TrySnapshot(options);
|
|
if (snapshot is null)
|
|
{
|
|
return this.InnerAgent.RunAsync(messages, session, options, cancellationToken);
|
|
}
|
|
|
|
return RunAsyncCoreAsync(messages, session, options, snapshot, cancellationToken);
|
|
|
|
async Task<AgentResponse> RunAsyncCoreAsync(
|
|
IEnumerable<ChatMessage> innerMessages,
|
|
AgentSession? innerSession,
|
|
AgentRunOptions? innerOptions,
|
|
Dictionary<string, string> innerSnapshot,
|
|
CancellationToken innerCt)
|
|
{
|
|
using var _ = ClientHeadersScope.Push(innerSnapshot);
|
|
return await this.InnerAgent.RunAsync(innerMessages, innerSession, innerOptions, innerCt).ConfigureAwait(false);
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(
|
|
IEnumerable<ChatMessage> messages,
|
|
AgentSession? session = null,
|
|
AgentRunOptions? options = null,
|
|
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
|
{
|
|
var snapshot = TrySnapshot(options);
|
|
using var _ = snapshot is null ? default : ClientHeadersScope.Push(snapshot);
|
|
|
|
await foreach (var update in this.InnerAgent.RunStreamingAsync(messages, session, options, cancellationToken).ConfigureAwait(false))
|
|
{
|
|
yield return update;
|
|
}
|
|
}
|
|
|
|
/// <summary>Reads the header dictionary stamped by <c>WithClientHeader(s)</c> and returns an immutable snapshot, or <see langword="null"/> if none.</summary>
|
|
private static Dictionary<string, string>? TrySnapshot(AgentRunOptions? options)
|
|
{
|
|
if (options is not ChatClientAgentRunOptions { ChatOptions: { } chatOptions })
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var headers = chatOptions.GetClientHeaders();
|
|
if (headers is null || headers.Count == 0)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
// Copy to defeat caller mutation after RunAsync starts.
|
|
var copy = new Dictionary<string, string>(headers.Count, System.StringComparer.OrdinalIgnoreCase);
|
|
foreach (var kvp in headers)
|
|
{
|
|
copy[kvp.Key] = kvp.Value;
|
|
}
|
|
|
|
return copy;
|
|
}
|
|
}
|