File: HostedAgent\AzureHostedAgentResource.cs
Web Access
Project: src\src\Aspire.Hosting.Foundry\Aspire.Hosting.Foundry.csproj (Aspire.Hosting.Foundry)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Globalization;
using System.IO.Hashing;
using System.Text;
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Azure;
using Aspire.Hosting.Azure.Provisioning;
using Aspire.Hosting.Pipelines;
using Aspire.Hosting.Publishing;
using Aspire.Hosting.Utils;
using Azure;
using Azure.AI.Projects;
using Azure.AI.Projects.Agents;
using Azure.Core;
using Azure.ResourceManager.Authorization.Models;
using Microsoft.Extensions.Logging;
using RoleManagementPrincipalType = Azure.ResourceManager.Authorization.Models.RoleManagementPrincipalType;
 
namespace Aspire.Hosting.Foundry;
 
/// <summary>
/// A Microsoft Foundry hosted agent resource.
/// </summary>
public class AzureHostedAgentResource : Resource, IResourceWithEnvironment
{
    // The "Azure AI User" built-in role (data-plane access to Foundry agents/inference). Granted to
    // the agent's own instance identity below, and to consumers that reference the agent (see
    // HostedAgentResourceBuilderExtensions.GrantHostedAgentConsumerRoles).
    internal const string AzureAIUserRoleDefinitionId = "53ca6127-db72-4b80-b1b0-d745d6d5456d";
    internal const string DefaultResponsesProtocolVersion = "2.0.0";
 
    /// <summary>
    /// Creates a new instance of the <see cref="AzureHostedAgentResource"/> class.
    /// </summary>
    public AzureHostedAgentResource([ResourceName] string name, IResource target, Action<HostedAgentConfiguration>? configure = null) : base(name)
    {
        ArgumentNullException.ThrowIfNull(target);
        Target = target;
        Configure = configure;
        Annotations.Add(new ManifestPublishingCallbackAnnotation(PublishAsync));
        // Set up steps for deploying this particular hosted agent
        Annotations.Add(new PipelineStepAnnotation(async (ctx) =>
        {
            List<PipelineStep> steps = [];
            var deploymentAnnotation = Target.GetDeploymentTargetAnnotation() ?? throw new InvalidOperationException($"Deployment target annotation is required on resource '{Target.Name}' to deploy as hosted agent.");
            var project = deploymentAnnotation.ComputeEnvironment as AzureCognitiveServicesProjectResource
                ?? throw new InvalidOperationException($"Compute environment for resource '{Target.Name}' must be an AzureCognitiveServicesProjectResource to deploy as hosted agent.");
 
            // Create a step to deploy container as agent
            var agentDeployStep = new PipelineStep
            {
                Name = $"deploy-{Name}",
                Action = async (ctx) =>
                {
                    var version = await DeployAsync(ctx, project).ConfigureAwait(false);
                    ctx.ReportingStep.Log(LogLevel.Information, new MarkdownString($"Successfully deployed **{Name}** as Hosted Agent (version {version})"));
                    Version.Set(version.Version);
                },
                Tags = [WellKnownPipelineTags.DeployCompute],
                RequiredBySteps = [WellKnownPipelineSteps.Deploy],
                Resource = this,
                DependsOnSteps = [WellKnownPipelineSteps.DeployPrereq, AzureEnvironmentResource.ProvisionInfrastructureStepName]
            };
            steps.Add(agentDeployStep);
 
            return steps;
        }));
 
        // Wire up pipeline steps we introduced above
        Annotations.Add(new PipelineConfigurationAnnotation(async (context) =>
        {
            // BuildCompute = build Docker images, so do that before pushing
            context.GetSteps(Target, WellKnownPipelineTags.BuildCompute).RequiredBy(context.GetSteps(Target, WellKnownPipelineTags.PushContainerImage));
 
            var agentDeployStep = context.GetSteps(this, WellKnownPipelineTags.DeployCompute);
 
            // The app deployment should depend on push steps from the target resource
            var pushSteps = context.GetSteps(Target, WellKnownPipelineTags.PushContainerImage);
            agentDeployStep.DependsOn(pushSteps);
        }));
    }
 
    /// <summary>
    /// Configuration action to customize the hosted agent definition during deployment.
    /// </summary>
    public Action<HostedAgentConfiguration>? Configure { get; set; }
 
    /// <summary>
    /// Once deployed, the version that is assigned to this hosted agent.
    /// </summary>
    public StaticValueProvider<string> Version { get; } = new();
 
    /// <summary>
    /// The fully qualified image name for the hosted agent.
    /// </summary>
    public ContainerImageReference Image => new(Target);
 
    /// <summary>
    /// The target containerized workload that this hosted agent deploys.
    /// </summary>
    public IResource Target { get; }
 
    /// <summary>
    /// Convert all dynamic values into concrete values for deployment.
    /// </summary>
    private async Task<HostedAgentConfiguration> ToHostedAgentConfigurationAsync(PipelineStepContext context)
    {
        var imageName = await ((IValueProvider)Image).GetValueAsync(context.CancellationToken).ConfigureAwait(false);
        if (string.IsNullOrEmpty(imageName))
        {
            throw new InvalidOperationException($"Container image for hosted agent '{Name}' could not be resolved.");
        }
 
        var def = new HostedAgentConfiguration(imageName)
        {
            // ProcessEnvironmentVariableValuesAsync does not resolve values properly in the deploy context
            EnvironmentVariables = await GetResolvedEnvironmentVariablesAsync(context.ExecutionContext, this, Target, context.Logger, context.CancellationToken).ConfigureAwait(false),
        };
        if (Configure is not null)
        {
            Configure(def);
        }
        EnsureProtocolVersions(def);
        return def;
    }
 
    internal static void EnsureProtocolVersions(HostedAgentConfiguration configuration)
    {
        if (configuration.ProtocolVersions.Count == 0)
        {
            configuration.ProtocolVersions.Add(new ProtocolVersionRecord(ProjectsAgentProtocol.Responses, DefaultResponsesProtocolVersion));
        }
    }
 
    /// <summary>
    /// Publishes the hosted agent during the manifest publishing phase.
    /// </summary>
    private async Task PublishAsync(ManifestPublishingContext ctx)
    {
        // Write agent manifest
        ctx.Writer.WriteString("type", "azure.ai.agent.v0");
        ctx.Writer.WriteStartObject("definition");
        ctx.Writer.WriteString("kind", "hosted");
        ctx.Writer.WriteString("target", Target.Name);
        ctx.Writer.WriteEndObject(); // definition
        ctx.TryAddDependentResources(Target);
    }
 
    /// <summary>
    /// Deploys the specified agent to the given Microsoft Foundry project.
    /// </summary>
    private async Task<ProjectsAgentVersion> DeployAsync(PipelineStepContext context, AzureCognitiveServicesProjectResource project)
    {
        ArgumentNullException.ThrowIfNull(project);
 
        var azureEnvironment = context.Model.Resources.OfType<AzureEnvironmentResource>().FirstOrDefault() ??
            throw new InvalidOperationException("AzureEnvironmentResource must be present in the application model.");
 
        var provisioningContext = await azureEnvironment.ProvisioningContextTask.Task.ConfigureAwait(false);
        var credential = provisioningContext.Credential;
 
        var projectEndpoint = await project.Endpoint.GetValueAsync(context.CancellationToken).ConfigureAwait(false);
        if (string.IsNullOrEmpty(projectEndpoint))
        {
            throw new InvalidOperationException($"Project '{project.Name}' does not have a valid connection string.");
        }
        var def = await ToHostedAgentConfigurationAsync(context).ConfigureAwait(false);
        var options = def.ToProjectsAgentVersionCreationOptions(Target.Name);
 
        var projectClient = new AIProjectClient(new Uri(projectEndpoint), credential);
        var result = await projectClient.AgentAdministrationClient.CreateAgentVersionAsync(
            Name,
            options,
            cancellationToken: context.CancellationToken
        ).ConfigureAwait(false);
 
        await UpdateAgentEndpointProtocolsAsync(projectClient.AgentAdministrationClient, def, context.CancellationToken).ConfigureAwait(false);
 
        // Foundry should do this automatically in the future.
        await AssignFoundryRoleToAgentIdentityAsync(context, project, result.Value, provisioningContext).ConfigureAwait(false);
 
        return result.Value;
    }
 
    private async Task UpdateAgentEndpointProtocolsAsync(AgentAdministrationClient agentsClient, HostedAgentConfiguration configuration, CancellationToken cancellationToken)
    {
        var endpointProtocols = GetAgentEndpointProtocols(configuration.ProtocolVersions);
        if (endpointProtocols.Count == 0)
        {
            return;
        }
 
        var endpoint = new AgentEndpoint();
        foreach (var protocol in endpointProtocols)
        {
            endpoint.Protocols.Add(protocol);
        }
 
        // Creating a hosted-agent version does not update the endpoint's advertised protocols;
        // keep routing in sync so endpoint-scoped invocations can reach the selected version.
        await agentsClient.PatchAgentObjectAsync(
            Name,
            new PatchAgentOptions
            {
                AgentEndpoint = endpoint
            },
            cancellationToken).ConfigureAwait(false);
    }
 
    internal static IReadOnlyList<AgentEndpointProtocol> GetAgentEndpointProtocols(IEnumerable<ProtocolVersionRecord> protocolVersions)
    {
        var endpointProtocols = new List<AgentEndpointProtocol>();
 
        foreach (var protocolVersion in protocolVersions)
        {
            var endpointProtocol = ToAgentEndpointProtocol(protocolVersion.Protocol);
            if (!endpointProtocols.Contains(endpointProtocol))
            {
                endpointProtocols.Add(endpointProtocol);
            }
        }
 
        return endpointProtocols;
    }
 
    private static AgentEndpointProtocol ToAgentEndpointProtocol(ProjectsAgentProtocol protocol)
    {
        return protocol.ToString() switch
        {
            "activity_protocol" => AgentEndpointProtocol.Activity,
            "invocations" => AgentEndpointProtocol.Invocations,
            "responses" => AgentEndpointProtocol.Responses,
            var value => new AgentEndpointProtocol(value)
        };
    }
 
    private async Task AssignFoundryRoleToAgentIdentityAsync(
        PipelineStepContext context,
        AzureCognitiveServicesProjectResource project,
        ProjectsAgentVersion version,
        ProvisioningContext provisioningContext)
    {
        var principalId = version.InstanceIdentity?.PrincipalId;
        if (string.IsNullOrEmpty(principalId))
        {
            context.Logger.LogWarning("Hosted agent '{Name}' version '{Version}' did not return an instance identity. The agent may not be able to access Foundry project storage.", Name, version.Version);
            return;
        }
 
        var foundryResourceId = await project.Parent.Id.GetValueAsync(context.CancellationToken).ConfigureAwait(false);
        if (string.IsNullOrEmpty(foundryResourceId))
        {
            context.Logger.LogWarning("Could not resolve the Microsoft Foundry resource ID for hosted agent '{Name}'. The agent identity '{PrincipalId}' may need the Cognitive Services User role assigned manually.", Name, principalId);
            return;
        }
 
        var subscriptionResourceId = provisioningContext.Subscription.Id.ToString();
        var roleDefinitionId = new ResourceIdentifier(
            $"{subscriptionResourceId}/providers/Microsoft.Authorization/roleDefinitions/{AzureAIUserRoleDefinitionId}");
 
        var assignmentName = StableGuid(principalId, roleDefinitionId.ToString(), foundryResourceId);
 
        var content = new RoleAssignmentCreateOrUpdateContent(roleDefinitionId, Guid.Parse(principalId))
        {
            PrincipalType = RoleManagementPrincipalType.ServicePrincipal
        };
 
        var resourceScope = new ResourceIdentifier(foundryResourceId);
        var assignments = provisioningContext.ArmClient.GetRoleAssignments(resourceScope);
 
        try
        {
            await assignments.CreateOrUpdateAsync(
                WaitUntil.Completed,
                assignmentName,
                content,
                context.CancellationToken).ConfigureAwait(false);
 
            context.Logger.LogInformation("Assigned Cognitive Services User role to hosted agent '{Name}' identity '{PrincipalId}'.", Name, principalId);
        }
        catch (RequestFailedException ex)
        {
            context.Logger.LogWarning(
                ex,
                "Could not create Cognitive Services User role assignment for hosted agent '{Name}' identity '{PrincipalId}' on Foundry resource '{FoundryResourceId}'. Create the role assignment manually.",
                Name,
                principalId,
                foundryResourceId);
        }
 
        static string StableGuid(params string[] values)
        {
            byte[] hash = XxHash128.Hash(
                Encoding.UTF8.GetBytes(string.Join("|", values)));
 
            return new Guid(hash).ToString();
        }
    }
 
    internal static async Task<Dictionary<string, string>> GetResolvedEnvironmentVariablesAsync(
        DistributedApplicationExecutionContext context,
        AzureHostedAgentResource hostedAgent,
        IResource resource,
        ILogger logger,
        CancellationToken cancellationToken)
    {
        var collectedEnvVars = new Dictionary<string, object>();
        if (resource.TryGetEnvironmentVariables(out var callbacks))
        {
            var envContext = new EnvironmentCallbackContext(context, resource, collectedEnvVars, cancellationToken)
            {
                Logger = logger
            };
 
            foreach (var callback in callbacks)
            {
                await callback.Callback(envContext).ConfigureAwait(false);
            }
        }
        var resolvedEnvVars = new Dictionary<string, string>();
        foreach (var (key, value) in collectedEnvVars)
        {
            if (HostedAgentConfiguration.IsReservedEnvironmentVariableName(key))
            {
                // Foundry injects platform-owned variables such as PORT itself. Some Aspire resource
                // types use these variables to model local/container startup, but forwarding them in
                // the hosted-agent definition causes Foundry to reject the version payload.
                logger.LogDebug("Environment variable '{Key}' for resource '{Name}' is reserved by Foundry Hosted Agents and will be skipped.", key, resource.Name);
                continue;
            }
 
            if (IsHostedAgentTargetPortValue(value, hostedAgent))
            {
                // Endpoint target-port variables model how a local process or container binds. Foundry
                // hosted agents own the container port contract during deployment, and their endpoint
                // resolver intentionally does not support EndpointProperty.TargetPort.
                logger.LogDebug("Environment variable '{Key}' for resource '{Name}' references the hosted agent target port and will be skipped.", key, resource.Name);
                continue;
            }
 
            switch (value)
            {
                case null:
                    resolvedEnvVars[key] = string.Empty;
                    break;
                case string s:
                    resolvedEnvVars[key] = s;
                    break;
                case IValueProvider provider:
                    resolvedEnvVars[key] = await ResolveValueProviderAsync(provider, context, hostedAgent, resource, key, cancellationToken).ConfigureAwait(false) ?? string.Empty;
                    break;
                case IFormattable f:
                    resolvedEnvVars[key] = f.ToString(null, CultureInfo.InvariantCulture);
                    break;
                default:
                    logger.LogWarning("Environment variable '{Key}' for resource '{Name}' has unknown value of type '{type}' and will be skipped.", key, resource.Name, value.GetType().FullName);
                    break;
            }
        }
        return resolvedEnvVars;
    }
 
    private static bool IsHostedAgentTargetPortValue(object? value, AzureHostedAgentResource hostedAgent)
    {
        return value switch
        {
            EndpointReferenceExpression endpointReferenceExpression => IsHostedAgentTargetPortExpression(endpointReferenceExpression, hostedAgent),
            ReferenceExpression referenceExpression when referenceExpression.IsConditional =>
                IsHostedAgentTargetPortValue(referenceExpression.Condition, hostedAgent) ||
                IsHostedAgentTargetPortValue(referenceExpression.WhenTrue, hostedAgent) ||
                IsHostedAgentTargetPortValue(referenceExpression.WhenFalse, hostedAgent),
            ReferenceExpression referenceExpression => referenceExpression.ValueProviders.Any(valueProvider => IsHostedAgentTargetPortValue(valueProvider, hostedAgent)),
            _ => false
        };
    }
 
    private static bool IsHostedAgentTargetPortExpression(EndpointReferenceExpression endpointReferenceExpression, AzureHostedAgentResource hostedAgent)
    {
        return endpointReferenceExpression.Property == EndpointProperty.TargetPort &&
            ReferenceEquals(endpointReferenceExpression.Endpoint.Resource, hostedAgent.Target);
    }
 
    private static async ValueTask<string?> ResolveValueProviderAsync(
        IValueProvider provider,
        DistributedApplicationExecutionContext context,
        AzureHostedAgentResource hostedAgent,
        IResource resource,
        string environmentVariableName,
        CancellationToken cancellationToken)
    {
        if (context.IsPublishMode)
        {
            switch (provider)
            {
                case EndpointReference endpointReference:
                    return await ResolvePublishedEndpointAsync(endpointReference.Property(EndpointProperty.Url), context, hostedAgent, resource, environmentVariableName, cancellationToken).ConfigureAwait(false);
                case EndpointReferenceExpression endpointReferenceExpression:
                    return await ResolvePublishedEndpointAsync(endpointReferenceExpression, context, hostedAgent, resource, environmentVariableName, cancellationToken).ConfigureAwait(false);
                case ReferenceExpression referenceExpression:
                    return await ResolveReferenceExpressionAsync(referenceExpression, context, hostedAgent, resource, environmentVariableName, cancellationToken).ConfigureAwait(false);
                case ConnectionStringReference connectionStringReference:
                    var connectionString = await ResolveReferenceExpressionAsync(connectionStringReference.Resource.ConnectionStringExpression, context, hostedAgent, resource, environmentVariableName, cancellationToken).ConfigureAwait(false);
                    if (string.IsNullOrEmpty(connectionString) && !connectionStringReference.Optional)
                    {
                        throw new DistributedApplicationException($"The connection string for the resource '{connectionStringReference.Resource.Name}' is not available.");
                    }
 
                    return connectionString;
                case IResourceWithConnectionString connectionStringResource and not ParameterResource:
                    return await ResolveReferenceExpressionAsync(connectionStringResource.ConnectionStringExpression, context, hostedAgent, resource, environmentVariableName, cancellationToken).ConfigureAwait(false);
            }
        }
 
        return await provider.GetValueAsync(
            new ValueProviderContext
            {
                ExecutionContext = context,
                Caller = resource
            },
            cancellationToken).ConfigureAwait(false);
    }
 
    private static async ValueTask<string?> ResolveReferenceExpressionAsync(
        ReferenceExpression expression,
        DistributedApplicationExecutionContext context,
        AzureHostedAgentResource hostedAgent,
        IResource resource,
        string environmentVariableName,
        CancellationToken cancellationToken)
    {
        if (expression.IsConditional)
        {
            var conditionValue = await ResolveValueProviderAsync(expression.Condition!, context, hostedAgent, resource, environmentVariableName, cancellationToken).ConfigureAwait(false);
            var branch = string.Equals(conditionValue, expression.MatchValue, StringComparison.OrdinalIgnoreCase) ? expression.WhenTrue! : expression.WhenFalse!;
 
            return await ResolveReferenceExpressionAsync(branch, context, hostedAgent, resource, environmentVariableName, cancellationToken).ConfigureAwait(false);
        }
 
        if (expression.Format.Length == 0)
        {
            return null;
        }
 
        var args = new object?[expression.ValueProviders.Count];
        for (var i = 0; i < expression.ValueProviders.Count; i++)
        {
            args[i] = await ResolveValueProviderAsync(expression.ValueProviders[i], context, hostedAgent, resource, environmentVariableName, cancellationToken).ConfigureAwait(false);
 
            if (expression.StringFormats[i] is string stringFormat && args[i] is string value)
            {
                args[i] = FormattingHelpers.FormatValue(value, stringFormat);
            }
        }
 
        return string.Format(CultureInfo.InvariantCulture, expression.Format, args);
    }
 
    private static async ValueTask<string?> ResolvePublishedEndpointAsync(
        EndpointReferenceExpression endpointReferenceExpression,
        DistributedApplicationExecutionContext context,
        AzureHostedAgentResource hostedAgent,
        IResource resource,
        string environmentVariableName,
        CancellationToken cancellationToken)
    {
        var endpointReference = endpointReferenceExpression.Endpoint;
        var endpoint = endpointReference.EndpointAnnotation;
 
        if (endpointReference.Resource != hostedAgent.Target && !endpoint.IsExternal)
        {
            throw CreateEndpointResolutionException(hostedAgent, resource, environmentVariableName, endpointReference, $"Endpoint '{endpoint.Name}' is internal. Foundry hosted agents can only reference externally exposed endpoints during publish.");
        }
 
        if (!ComputeEnvironmentEndpointResolver.TryGetEffectiveComputeEnvironment(endpointReference.Resource, out var computeEnvironment))
        {
            var reason = $"Resource '{endpointReference.Resource.Name}' does not have a compute environment deployment target.";
            throw CreateEndpointResolutionException(hostedAgent, resource, environmentVariableName, endpointReference, reason);
        }
 
#pragma warning disable ASPIRECOMPUTE002
        var expression = computeEnvironment.GetEndpointPropertyExpression(endpointReferenceExpression);
#pragma warning restore ASPIRECOMPUTE002
 
        return await expression.GetValueAsync(
            new ValueProviderContext
            {
                ExecutionContext = context,
                Caller = resource
            },
            cancellationToken).ConfigureAwait(false);
    }
 
    private static InvalidOperationException CreateEndpointResolutionException(
        AzureHostedAgentResource hostedAgent,
        IResource resource,
        string environmentVariableName,
        EndpointReference endpointReference,
        string reason)
    {
        return new InvalidOperationException(
            $"Unable to resolve environment variable '{environmentVariableName}' for Foundry hosted agent '{hostedAgent.Name}' from target resource '{resource.Name}'. " +
            $"Endpoint '{endpointReference.EndpointName}' on resource '{endpointReference.Resource.Name}' cannot be used. {reason}");
    }
 
}
 
/// <summary>
/// A static value provider that returns a fixed value once it's been set.
/// </summary>
 
public class StaticValueProvider<T> : IValueProvider, IManifestExpressionProvider
{
    private T? _value;
    private bool _isSet;
 
    /// <inheritdoc/>
    public string ValueExpression => "{value}";
 
    /// <summary>
    /// Sets the value of the provider.
    /// </summary>
    public void Set(T value)
    {
        if (_isSet)
        {
            throw new InvalidOperationException($"Value has already been set.");
        }
        _value = value;
        _isSet = true;
    }
 
    /// <summary>
    /// Creates a new instance of the <see cref="StaticValueProvider{T}"/> class.
    /// </summary>
    public StaticValueProvider()
    {
        _isSet = false;
    }
 
    /// <summary>
    /// Creates a new instance of the <see cref="StaticValueProvider{T}"/> class.
    /// </summary>
    public StaticValueProvider(T value)
    {
        _value = value;
        _isSet = true;
    }
 
    /// <inheritdoc/>
    public ValueTask<string?> GetValueAsync(CancellationToken cancellationToken = default)
    {
        if (_isSet == false)
        {
            throw new InvalidOperationException("Value for provider has not been set.");
        }
        else
        {
            return ValueTask.FromResult(_value?.ToString());
        }
    }
}