Summary
Microsoft.Agents.AI.Foundry.ClientHeadersAgent uses an AsyncLocal (ClientHeadersScope.Current) to carry per-run x-client-* headers down to ClientHeadersPolicy, which stamps them onto the outbound OpenAI/Foundry request.
In the non-streaming RunAsync path the ambient scope is not deterministically restored on return. When two runs execute on the same async flow and the second supplies fresh ChatOptions without client headers, the second run can inherit the first run's x-client-end-user-id. This is a robustness/correctness gap: incorrect per-call header attribution for hosts that key logging, routing, or per-user handling off these headers.
The streaming path (RunCoreStreamingAsync) already restores naturally because it is an async iterator, so the gap is non-streaming only.
Affected component
dotnet/src/Microsoft.Agents.AI.Foundry/ClientHeadersAgent.cs
- Related:
ClientHeadersScope.cs (carrier), ClientHeadersPolicy.cs (stamping), ClientHeadersExtensions.cs (API)
Root cause
ClientHeadersAgent.RunCoreAsync was a synchronous method (no async) that set ClientHeadersScope.Current and returned InnerAgent.RunAsync(...) directly. The public AIAgent.RunAsync is also a synchronous pass-through, so there is no async state-machine boundary between the caller and the scope write. The execution-context capture/restore that normally unwinds AsyncLocal writes only runs for async methods, so the write carried into the caller's ExecutionContext and persisted. A later headerless run (TrySnapshot(...) == null) then kept the stale value, which the policy stamped on the wire.
Reproduction
Two unit tests in ClientHeadersExtensionsTests.cs:
NonStreaming_DoesNotCarryClientHeadersToSubsequentRunAsync
EndToEnd_NonStreaming_SecondRunDoesNotInheritHeaderOnWireAsync
Before the fix, both fail: the second run observes x-client-end-user-id=alice even though it supplied no client headers, and the second wire request carries the header.
Proposed fix
Make ClientHeadersAgent.RunCoreAsync an async method that awaits the inner call. This establishes the async-method execution-context boundary the code already assumed, so the per-run scope set is unwound on return, mirroring the already-correct streaming path. Negligible cost (one state machine); no public API change.
Scope
Niche, defensive hardening change. It does not alter which headers are requested per call; it only ensures a previous run's headers are not carried into a later headerless run on the same async flow.
Summary
Microsoft.Agents.AI.Foundry.ClientHeadersAgentuses anAsyncLocal(ClientHeadersScope.Current) to carry per-runx-client-*headers down toClientHeadersPolicy, which stamps them onto the outbound OpenAI/Foundry request.In the non-streaming
RunAsyncpath the ambient scope is not deterministically restored on return. When two runs execute on the same async flow and the second supplies freshChatOptionswithout client headers, the second run can inherit the first run'sx-client-end-user-id. This is a robustness/correctness gap: incorrect per-call header attribution for hosts that key logging, routing, or per-user handling off these headers.The streaming path (
RunCoreStreamingAsync) already restores naturally because it is anasynciterator, so the gap is non-streaming only.Affected component
dotnet/src/Microsoft.Agents.AI.Foundry/ClientHeadersAgent.csClientHeadersScope.cs(carrier),ClientHeadersPolicy.cs(stamping),ClientHeadersExtensions.cs(API)Root cause
ClientHeadersAgent.RunCoreAsyncwas a synchronous method (noasync) that setClientHeadersScope.Currentand returnedInnerAgent.RunAsync(...)directly. The publicAIAgent.RunAsyncis also a synchronous pass-through, so there is noasyncstate-machine boundary between the caller and the scope write. The execution-context capture/restore that normally unwindsAsyncLocalwrites only runs forasyncmethods, so the write carried into the caller'sExecutionContextand persisted. A later headerless run (TrySnapshot(...) == null) then kept the stale value, which the policy stamped on the wire.Reproduction
Two unit tests in
ClientHeadersExtensionsTests.cs:NonStreaming_DoesNotCarryClientHeadersToSubsequentRunAsyncEndToEnd_NonStreaming_SecondRunDoesNotInheritHeaderOnWireAsyncBefore the fix, both fail: the second run observes
x-client-end-user-id=aliceeven though it supplied no client headers, and the second wire request carries the header.Proposed fix
Make
ClientHeadersAgent.RunCoreAsyncanasyncmethod thatawaits the inner call. This establishes the async-method execution-context boundary the code already assumed, so the per-run scope set is unwound on return, mirroring the already-correct streaming path. Negligible cost (one state machine); no public API change.Scope
Niche, defensive hardening change. It does not alter which headers are requested per call; it only ensures a previous run's headers are not carried into a later headerless run on the same async flow.