Skip to content

.NET: Hardening: restore ambient client-header scope between non-streaming ClientHeadersAgent runs #6516

@rogerbarreto

Description

@rogerbarreto

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.

Metadata

Metadata

Assignees

Labels

Type

No type
No fields configured for issues without a type.

Projects

Status
Done

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions