Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.

using System;
using System.ClientModel.Primitives;
using System.Collections.Generic;
using System.Reflection;
Expand All @@ -9,8 +10,11 @@
namespace Microsoft.Agents.AI.Foundry.Hosting;

/// <summary>
/// Pipeline policy that appends the hosted-agent <c>User-Agent</c> segment
/// (e.g. <c>"foundry-hosting/agent-framework-dotnet/{version}"</c>) to outgoing requests.
/// Pipeline policy that emits the hosted-agent <c>User-Agent</c> segment
/// (<c>"foundry-hosting/agent-framework-dotnet/{version}"</c>), matching Python's hosted
/// contract (<c>foundry-hosting/agent-framework-python/{version}</c>, see
/// <c>python/packages/core/agent_framework/_telemetry.py</c>: the hosted prefix is joined
/// with the base agent-framework segment into a single combined User-Agent value).
/// </summary>
/// <remarks>
/// <para>
Expand All @@ -19,6 +23,12 @@ namespace Microsoft.Agents.AI.Foundry.Hosting;
/// is already present in the <c>User-Agent</c> header, the policy does not append it again.
/// </para>
/// <para>
/// When a bare <c>agent-framework-dotnet/{version}</c> segment is already present (stamped by
/// the framework-wide <c>AgentFrameworkUserAgentPolicy</c> registered by
/// <c>FoundryChatClient</c>), this policy <em>replaces</em> that segment with the combined
/// hosted form so the wire never carries both forms simultaneously, preserving Python parity.
/// </para>
/// <para>
/// This policy is added at hosted-agent resolution time via the MEAI 10.5.1
/// <see cref="OpenAIRequestPolicies"/> hook on the agent's underlying chat client. It is only
/// registered when an agent is resolved by the Foundry hosting layer.
Expand All @@ -30,6 +40,12 @@ internal sealed class HostedAgentUserAgentPolicy : PipelinePolicy

private static readonly string s_supplementValue = CreateSupplementValue();

/// <summary>Bare segment stamped by <c>AgentFrameworkUserAgentPolicy</c> in the non-hosted scenario; this policy upgrades it in-place when both run.</summary>
private const string BareAgentFrameworkPrefix = "agent-framework-dotnet/";

/// <summary>Combined hosted segment that this policy emits. Recognized in-place so callers whose pipelines already carry a (possibly different-version) combined segment get it replaced rather than double-prefixed (Q-D fix).</summary>
private const string CombinedHostedPrefix = "foundry-hosting/agent-framework-dotnet/";

public override void Process(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
{
AppendHeader(message);
Expand All @@ -46,10 +62,49 @@ private static void AppendHeader(PipelineMessage message)
{
if (message.Request.Headers.TryGetValue("User-Agent", out var existing) && !string.IsNullOrEmpty(existing))
{
// Guard against double-append on retries or when the policy
// is registered on multiple pipeline positions.
if (existing.Contains(s_supplementValue))
// Guard against double-append on retries or when the policy is registered on
// multiple pipeline positions.
if (existing!.Contains(s_supplementValue))
{
return;
}

// Combined-form check first: if the caller's pipeline already has
// `foundry-hosting/agent-framework-dotnet/{version}` (with a version that differs
// from ours — otherwise the .Contains above would have returned early), replace the
// entire combined span in place. Without this, the bare-prefix search below would
// match `agent-framework-dotnet/` *inside* the combined segment and produce a
// malformed `foundry-hosting/foundry-hosting/agent-framework-dotnet/...` value.
var combinedIdx = existing.IndexOf(CombinedHostedPrefix, StringComparison.Ordinal);
if (combinedIdx >= 0)
{
var combinedEnd = existing.IndexOf(' ', combinedIdx);
if (combinedEnd < 0)
{
combinedEnd = existing.Length;
}

var replacedCombined = string.Concat(existing.AsSpan(0, combinedIdx), s_supplementValue.AsSpan(), existing.AsSpan(combinedEnd));
message.Request.Headers.Set("User-Agent", replacedCombined);
return;
}

// If the bare agent-framework segment is present (stamped by
// AgentFrameworkUserAgentPolicy when not hosted), upgrade it in place to the
// combined hosted form so the wire never carries both segments simultaneously.
// Mirrors Python where get_user_agent() returns a single combined string when the
// hosted prefix is registered.
var idx = existing.IndexOf(BareAgentFrameworkPrefix, StringComparison.Ordinal);
if (idx >= 0)
{
var end = existing.IndexOf(' ', idx);
if (end < 0)
{
end = existing.Length;
}

var replaced = string.Concat(existing.AsSpan(0, idx), s_supplementValue.AsSpan(), existing.AsSpan(end));
message.Request.Headers.Set("User-Agent", replaced);
return;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ namespace Azure.AI.Projects;
/// Provides extension methods for <see cref="AIProjectClient"/>.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
public static partial class AzureAIProjectChatClientExtensions
public static partial class AIProjectClientExtensions
{
/// <summary>
/// Uses an existing server side agent, wrapped as a <see cref="ChatClientAgent"/> using the provided <see cref="AIProjectClient"/> and <see cref="AgentReference"/>.
Expand Down Expand Up @@ -63,7 +63,7 @@ public static FoundryAgent AsAIAgent(
clientFactory,
services);

return new FoundryAgent(aiProjectClient, innerAgent);
return new FoundryAgent(innerAgent);
}

/// <summary>
Expand Down Expand Up @@ -132,7 +132,7 @@ public static FoundryAgent AsAIAgent(
!allowDeclarativeMode,
services);

return new FoundryAgent(aiProjectClient, innerAgent);
return new FoundryAgent(innerAgent);
}

/// <summary>
Expand Down Expand Up @@ -165,7 +165,7 @@ public static FoundryAgent AsAIAgent(
!allowDeclarativeMode,
services);

return new FoundryAgent(aiProjectClient, innerAgent);
return new FoundryAgent(innerAgent);
}

/// <summary>
Expand Down Expand Up @@ -246,7 +246,7 @@ private static ChatClientAgent CreateChatClientAgent(
Func<IChatClient, IChatClient>? clientFactory,
IServiceProvider? services)
{
IChatClient chatClient = new AzureAIProjectChatClient(aiProjectClient, agentVersion, agentOptions.ChatOptions);
IChatClient chatClient = new FoundryChatClient(aiProjectClient, agentVersion, agentOptions.ChatOptions);

if (clientFactory is not null)
{
Expand All @@ -268,10 +268,7 @@ private static ChatClientAgent CreateResponsesChatClientAgent(
Throw.IfNull(agentOptions.ChatOptions);
Throw.IfNullOrWhitespace(agentOptions.ChatOptions.ModelId);

IChatClient chatClient = aiProjectClient
.GetProjectOpenAIClient()
.GetResponsesClient()
.AsIChatClient(agentOptions.ChatOptions.ModelId);
IChatClient chatClient = new FoundryChatClient(aiProjectClient, agentOptions.ChatOptions.ModelId);

if (clientFactory is not null)
{
Expand All @@ -298,7 +295,7 @@ private static ChatClientAgent AsChatClientAgent(
Func<IChatClient, IChatClient>? clientFactory,
IServiceProvider? services)
{
IChatClient chatClient = new AzureAIProjectChatClient(aiProjectClient, agentRecord, agentOptions.ChatOptions);
IChatClient chatClient = new FoundryChatClient(aiProjectClient, agentRecord, agentOptions.ChatOptions);

if (clientFactory is not null)
{
Expand All @@ -316,7 +313,7 @@ private static ChatClientAgent AsChatClientAgent(
Func<IChatClient, IChatClient>? clientFactory,
IServiceProvider? services)
{
IChatClient chatClient = new AzureAIProjectChatClient(aiProjectClient, agentReference, defaultModelId: null, agentOptions.ChatOptions);
IChatClient chatClient = new FoundryChatClient(aiProjectClient, agentReference, defaultModelId: null, agentOptions.ChatOptions);

if (clientFactory is not null)
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
// Copyright (c) Microsoft. All rights reserved.

using System.ClientModel.Primitives;
using System.Collections.Generic;
using System.Reflection;
using System.Threading.Tasks;

namespace Microsoft.Agents.AI.Foundry;

/// <summary>
/// Framework-wide pipeline policy that appends the <c>agent-framework-dotnet/{version}</c>
/// segment to outgoing <c>User-Agent</c> headers, mirroring the
/// <c>agent-framework-python/{version}</c> contract used by every Python provider package.
/// </summary>
/// <remarks>
/// <para>
/// The segment value is computed once from the <c>Microsoft.Agents.AI.Foundry</c> assembly's
/// <see cref="AssemblyInformationalVersionAttribute"/>. The policy is idempotent on retries: if
/// the segment is already present in the <c>User-Agent</c> header, the policy does not append
/// it again.
/// </para>
/// <para>
/// The policy is registered by <c>FoundryChatClient</c> on the underlying chat client's
/// <c>OpenAIRequestPolicies</c> hook so every outbound Foundry call carries the segment. The
/// policy is currently colocated with the Foundry package; it is expected to migrate to a
/// framework-wide location (such as <c>Microsoft.Agents.AI</c>) once another provider package
/// adopts the same User-Agent contract.
/// </para>
/// </remarks>
internal sealed class AgentFrameworkUserAgentPolicy : PipelinePolicy
{
/// <summary>Gets the singleton policy instance.</summary>
public static AgentFrameworkUserAgentPolicy Instance { get; } = new AgentFrameworkUserAgentPolicy();

private static readonly string s_segmentValue = CreateSegmentValue();

public override void Process(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
{
AppendHeader(message);
ProcessNext(message, pipeline, currentIndex);
}

public override async ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
{
AppendHeader(message);
await ProcessNextAsync(message, pipeline, currentIndex).ConfigureAwait(false);
}

private static void AppendHeader(PipelineMessage message)
{
if (message.Request.Headers.TryGetValue("User-Agent", out var existing) && !string.IsNullOrEmpty(existing))
{
// Guard against double-append on retries or when the policy
// is registered on multiple pipeline positions.
if (existing!.Contains(s_segmentValue))
{
return;
}

message.Request.Headers.Set("User-Agent", $"{existing} {s_segmentValue}");
}
else
{
message.Request.Headers.Set("User-Agent", s_segmentValue);
}
}

private static string CreateSegmentValue()
{
const string Name = "agent-framework-dotnet";

if (typeof(AgentFrameworkUserAgentPolicy).Assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion is string version)
{
int pos = version.IndexOf('+');
if (pos >= 0)
{
version = version.Substring(0, pos);
}

if (version.Length > 0)
{
return $"{Name}/{version}";
}
}

return Name;
}
}
Loading
Loading