// Copyright (c) Microsoft. All rights reserved. using System; using System.ClientModel; using System.ClientModel.Primitives; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Threading; using System.Threading.Tasks; using Azure.AI.Extensions.OpenAI; using Azure.AI.Projects; using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging; using Microsoft.Shared.DiagnosticIds; using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.AI.Foundry; /// /// Provides an that uses Microsoft Foundry for AI agent capabilities. /// /// /// /// connects to a pre-configured server-side agent in Microsoft Foundry, /// wrapping it as an for use with Agent Framework. Unlike the direct /// AIProjectClient.AsAIAgent(model, instructions) approach (which creates a local agent /// backed by the Responses API without any server-side agent definition), /// works with agents that are managed and versioned in the Foundry service. /// /// /// This class provides convenient access to Foundry-specific features such as server-side /// conversation management via . /// /// /// Instances can be created directly via public constructors or through /// AsAIAgent extension methods on . /// /// [Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)] public sealed class FoundryAgent : DelegatingAIAgent { /// /// Default OAuth scope for the Azure AI resource. Matches the scope used by /// Azure.AI.Extensions.OpenAI's internal authentication helper so the bearer token is /// accepted by the Foundry control plane. /// private const string AzureAiResourceScope = "https://ai.azure.com/.default"; /// /// The cached when one was supplied or constructed by the active /// constructor. Null when the agent was constructed via the agent-endpoint constructor, which /// does not build a full . /// private readonly AIProjectClient? _aiProjectClient; /// /// Project-scoped . Always non-null. Used for project-level /// operations such as . /// In agent-endpoint mode this is built directly from the project root derived from the /// supplied agent endpoint; in project-endpoint mode it is the cached client returned by /// . /// private readonly ProjectOpenAIClient _projectOpenAIClient; /// /// Initializes a new instance of the class using the direct Responses API path. /// /// The Microsoft Foundry project endpoint. /// The authentication credential. /// The model deployment name. /// The instructions that guide the agent's behavior. /// Optional configuration options for the . /// Optional name for the agent. /// Optional description for the agent. /// Optional tools to use when interacting with the agent. /// Provides a way to customize the creation of the underlying . /// Optional logger factory for creating loggers used by the agent. /// Optional service provider for resolving dependencies required by AI functions. public FoundryAgent( Uri projectEndpoint, AuthenticationTokenProvider credential, string model, string instructions, AIProjectClientOptions? clientOptions = null, string? name = null, string? description = null, IList? tools = null, Func? clientFactory = null, ILoggerFactory? loggerFactory = null, IServiceProvider? services = null) : base(CreateInnerAgent( CreateProjectClient(projectEndpoint, credential, clientOptions), model, instructions, name, description, tools, clientFactory, loggerFactory, services, out var aiProjectClient)) { this._aiProjectClient = aiProjectClient; this._projectOpenAIClient = aiProjectClient.GetProjectOpenAIClient(); } /// /// Initializes a new instance of the class from an agent-specific endpoint. /// /// /// The agent-specific endpoint URI. Must be of the shape /// https://<host>/.../projects/<project>/agents/<agentName>/endpoint/protocols/openai. /// /// The authentication credential. /// /// Optional configuration for the underlying . When supplied: /// /// The instance is passed through to the per-agent client; pipeline policies added via AddPolicy(...) on it execute on the per-agent traffic. /// Endpoint and are owned by this constructor and are overwritten with values derived from ; any caller value is replaced. /// For the project-level conversations client a separate fresh options bag is built that copies only , , , and UserAgentApplicationId; pipeline policies added via AddPolicy(...) do not propagate to the conversations pipeline. /// /// /// Optional tools to use when interacting with the agent. /// Provides a way to customize the creation of the underlying . /// Optional service provider for resolving dependencies required by AI functions. /// or is null. /// does not match the expected agent-endpoint shape. /// /// This is the lightweight constructor for invoking an existing Foundry hosted agent when the /// caller already has the per-agent endpoint URL. It populates /// and from the agent name parsed out of the endpoint /// path; Description, Instructions, Temperature, and TopP are not /// populated. Callers that need those fields hydrated from server-side state should use /// AIProjectClient.AsAIAgent(ProjectsAgentVersion) or /// AIProjectClient.AsAIAgent(ProjectsAgentRecord) instead. /// public FoundryAgent( Uri agentEndpoint, AuthenticationTokenProvider credential, ProjectOpenAIClientOptions? clientOptions = null, IList? tools = null, Func? clientFactory = null, IServiceProvider? services = null) : base(CreateInnerAgentFromAgentEndpoint(agentEndpoint, credential, clientOptions, tools, clientFactory, services)) { this._projectOpenAIClient = CreateProjectLevelOpenAIClientFromAgentEndpoint(agentEndpoint, credential, clientOptions); } /// /// Internal constructor used by AsAIAgent extension methods that already have an and a configured . /// internal FoundryAgent(AIProjectClient aiProjectClient, ChatClientAgent innerAgent) : base(WireClientHeaders(Throw.IfNull(innerAgent))) { this._aiProjectClient = Throw.IfNull(aiProjectClient); this._projectOpenAIClient = aiProjectClient.GetProjectOpenAIClient(); } #region Convenience methods /// /// Creates a new agent session instance using an existing conversation identifier to continue that conversation. /// /// The identifier of an existing conversation to continue. /// The to monitor for cancellation requests. /// /// A value task representing the asynchronous operation. The task result contains a new instance configured to work with the specified conversation. /// /// /// /// This method creates an that relies on server-side chat history storage, where the chat history /// is maintained by the underlying AI service rather than by a local . /// /// /// Agent sessions created with this method will only work with /// instances that support server-side conversation storage through their underlying . /// /// public ValueTask CreateSessionAsync(string conversationId, CancellationToken cancellationToken = default) => this.GetInnerChatClientAgent().CreateSessionAsync(conversationId, cancellationToken); /// /// Creates a server-side conversation session that appears in the Foundry Project UI. /// /// A token to monitor for cancellation requests. /// A linked to the newly created server-side conversation. public async Task CreateConversationSessionAsync(CancellationToken cancellationToken = default) { var conversationsClient = this._projectOpenAIClient.GetProjectConversationsClient(); var conversation = (await conversationsClient.CreateProjectConversationAsync(options: null, cancellationToken).ConfigureAwait(false)).Value; return (ChatClientAgentSession)await this.GetInnerChatClientAgent().CreateSessionAsync(conversation.Id, cancellationToken).ConfigureAwait(false); } /// Walks the delegating chain to find the inner . private ChatClientAgent GetInnerChatClientAgent() => this.GetService() ?? throw new InvalidOperationException("FoundryAgent inner chain does not contain a ChatClientAgent."); #endregion /// public override object? GetService(Type serviceType, object? serviceKey = null) { if (serviceKey is null && serviceType == typeof(AIProjectClient)) { return this._aiProjectClient; } if (serviceKey is null && serviceType == typeof(ProjectOpenAIClient)) { return this._projectOpenAIClient; } return base.GetService(serviceType, serviceKey); } #region Private helpers private static AIAgent CreateInnerAgent( AIProjectClient aiProjectClient, string model, string instructions, string? name, string? description, IList? tools, Func? clientFactory, ILoggerFactory? loggerFactory, IServiceProvider? services, out AIProjectClient outClient) { Throw.IfNullOrWhitespace(model); Throw.IfNullOrWhitespace(instructions); outClient = aiProjectClient; ChatClientAgentOptions options = new() { Name = name, Description = description, ChatOptions = new ChatOptions { ModelId = model, Instructions = instructions, Tools = tools, }, }; return CreateResponsesChatClientAgent(aiProjectClient, options, clientFactory, loggerFactory, services); } private static AIAgent CreateResponsesChatClientAgent( AIProjectClient aiProjectClient, ChatClientAgentOptions agentOptions, Func? clientFactory, ILoggerFactory? loggerFactory, IServiceProvider? services) { Throw.IfNull(aiProjectClient); Throw.IfNull(agentOptions); Throw.IfNull(agentOptions.ChatOptions); Throw.IfNullOrWhitespace(agentOptions.ChatOptions.ModelId); IChatClient chatClient = new AzureAIProjectResponsesChatClient(aiProjectClient, agentOptions.ChatOptions.ModelId); if (clientFactory is not null) { chatClient = clientFactory(chatClient); } return WireClientHeaders(new ChatClientAgent(chatClient, agentOptions, loggerFactory, services)); } /// /// Registers on the agent's underlying chat client (if it /// exposes ) and wraps the agent in a /// so per-call x-client-* headers stamped via /// reach /// the wire. Idempotent: if the chain already contains a , /// the original instance is returned unchanged. /// private static AIAgent WireClientHeaders(ChatClientAgent innerAgent) { if (innerAgent.GetService() is not null) { return innerAgent; } if (innerAgent.ChatClient.GetService() is { } policies) { OpenAIRequestPoliciesReflection.AddPolicyIfMissing( policies, ClientHeadersPolicy.Instance, PipelinePosition.PerCall); } return new ClientHeadersAgent(innerAgent); } /// /// Builds the inner for the agent-endpoint constructor by /// constructing a per-agent via the /// ProjectOpenAIClient(AuthenticationPolicy, ProjectOpenAIClientOptions) /// constructor with set. This routes the /// outbound URL through the per-agent endpoint shape that the Foundry service expects for /// hosted agents and lets the SDK auto-append the api-version query string. /// Caller-supplied are passed through to the per-agent /// client with Endpoint and /// overridden by values derived from /// ; any policies the caller added via AddPolicy /// remain in effect on the per-agent pipeline. The MEAI user-agent policy is appended last. /// private static AIAgent CreateInnerAgentFromAgentEndpoint( Uri agentEndpoint, AuthenticationTokenProvider credential, ProjectOpenAIClientOptions? clientOptions, IList? tools, Func? clientFactory, IServiceProvider? services) { Throw.IfNull(agentEndpoint); Throw.IfNull(credential); var (agentName, _) = ParseAgentEndpoint(agentEndpoint); var perAgentOptions = clientOptions ?? new ProjectOpenAIClientOptions(); perAgentOptions.Endpoint = agentEndpoint; perAgentOptions.AgentName = agentName; perAgentOptions.AddPolicy(RequestOptionsExtensions.UserAgentPolicy, PipelinePosition.PerCall); var authPolicy = new BearerTokenPolicy(credential, AzureAiResourceScope); var perAgentClient = new ProjectOpenAIClient(authPolicy, perAgentOptions); IChatClient chatClient = perAgentClient.GetProjectResponsesClient().AsIChatClient(); if (clientFactory is not null) { chatClient = clientFactory(chatClient); } ChatClientAgentOptions agentOptions = new() { Id = agentName, Name = agentName, ChatOptions = new() { Tools = tools }, }; return WireClientHeaders(new ChatClientAgent(chatClient, agentOptions, services: services)); } /// /// Builds the project-scoped for the agent-endpoint /// constructor by deriving the project root from the supplied agent endpoint and constructing /// a fresh client without so the SDK /// appends the standard /openai/v1 suffix expected for project-level surfaces such as /// conversations. /// /// /// Only the four observable primitive properties (, /// , , /// and UserAgentApplicationId) are copied from the caller's options bag. Pipeline /// policies added via AddPolicy on the caller bag do not propagate because /// does not publicly enumerate its policies. The MEAI /// user-agent policy is appended last. /// private static ProjectOpenAIClient CreateProjectLevelOpenAIClientFromAgentEndpoint( Uri agentEndpoint, AuthenticationTokenProvider credential, ProjectOpenAIClientOptions? clientOptions) { var (_, projectRoot) = ParseAgentEndpoint(agentEndpoint); var projectOptions = new ProjectOpenAIClientOptions(); if (clientOptions is not null) { if (clientOptions.RetryPolicy is not null) { projectOptions.RetryPolicy = clientOptions.RetryPolicy; } if (clientOptions.NetworkTimeout is not null) { projectOptions.NetworkTimeout = clientOptions.NetworkTimeout; } if (clientOptions.Transport is not null) { projectOptions.Transport = clientOptions.Transport; } if (!string.IsNullOrEmpty(clientOptions.UserAgentApplicationId)) { projectOptions.UserAgentApplicationId = clientOptions.UserAgentApplicationId; } } projectOptions.AddPolicy(RequestOptionsExtensions.UserAgentPolicy, PipelinePosition.PerCall); return new ProjectOpenAIClient(projectRoot, credential, projectOptions); } /// /// Parses an agent endpoint URI of shape /// https://<host>/.../projects/<project>/agents/<agentName>/endpoint/protocols/openai /// and returns the agent name and the derived project-root URI. /// /// /// Single source of truth for both agent-name extraction and project-root derivation. /// Tolerates trailing slash, casing variants on /agents/ and the suffix segment, and /// strips query string and fragment. Throws for inputs that /// do not match the expected shape. /// /// /// The endpoint is missing the /agents/ segment, has an empty agent name, or has a /// suffix other than /endpoint/protocols/openai. /// internal static (string AgentName, Uri ProjectRoot) ParseAgentEndpoint(Uri agentEndpoint) { Throw.IfNull(agentEndpoint); const string AgentsSegment = "/agents/"; const string ExpectedSuffix = "/endpoint/protocols/openai"; var path = agentEndpoint.AbsolutePath.TrimEnd('/'); var idx = path.IndexOf(AgentsSegment, StringComparison.OrdinalIgnoreCase); if (idx < 0) { throw new ArgumentException( $"Expected an agent endpoint of shape 'https:///.../projects//agents//endpoint/protocols/openai' but got '{agentEndpoint}'. " + "If you want to construct a FoundryAgent against a project endpoint, use the (Uri projectEndpoint, AuthenticationTokenProvider credential, string model, string instructions, ...) constructor instead.", nameof(agentEndpoint)); } var afterAgents = path.Substring(idx + AgentsSegment.Length); var nextSlash = afterAgents.IndexOf('/'); if (nextSlash <= 0) { throw new ArgumentException( $"Agent endpoint '{agentEndpoint}' is missing the '{ExpectedSuffix}' suffix.", nameof(agentEndpoint)); } var agentName = afterAgents.Substring(0, nextSlash); var suffix = afterAgents.Substring(nextSlash); if (!string.Equals(suffix, ExpectedSuffix, StringComparison.OrdinalIgnoreCase)) { throw new ArgumentException( $"Agent endpoint '{agentEndpoint}' has an unexpected suffix '{suffix}'. Expected '{ExpectedSuffix}'.", nameof(agentEndpoint)); } var rootPath = path.Substring(0, idx); var projectRoot = new UriBuilder(agentEndpoint) { Path = rootPath, Query = string.Empty, Fragment = string.Empty, }.Uri; return (agentName, projectRoot); } private static AIProjectClient CreateProjectClient(Uri endpoint, AuthenticationTokenProvider credential, AIProjectClientOptions? clientOptions = null) { Throw.IfNull(endpoint); Throw.IfNull(credential); clientOptions ??= new AIProjectClientOptions(); clientOptions.AddPolicy(RequestOptionsExtensions.UserAgentPolicy, PipelinePosition.PerCall); return new AIProjectClient(endpoint, credential, clientOptions); } #endregion }