File: HostedAgent\HostedAgentBuilderExtension.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.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Nodes;
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Foundry;
using Microsoft.Extensions.DependencyInjection;
 
namespace Aspire.Hosting;
 
/// <summary>
/// Extension methods for adding hosted agent applications to the distributed application model.
/// </summary>
public static class HostedAgentResourceBuilderExtensions
{
    /// <summary>
    /// In both run and publish modes, build, deploy, and run the containerized agent as a hosted agent in Microsoft Foundry.
    /// </summary>
    /// <remarks>This overload is not available in polyglot app hosts. Use other Foundry project APIs to configure hosted agents from .NET.</remarks>
    [AspireExportIgnore(Reason = "HostedAgentConfiguration callback parameters are not ATS-compatible.")]
    public static IResourceBuilder<T> AsHostedAgent<T>(
        this IResourceBuilder<T> builder, Action<HostedAgentConfiguration>? configure = null)
        where T : ExecutableResource
    {
        return builder.AsHostedAgent(project: null, configure: configure);
    }
 
    /// <summary>
    /// In both run and publish modes, build, deploy, and run the containerized agent as a hosted agent in Microsoft Foundry.
    /// </summary>
    /// <remarks>This overload is not available in polyglot app hosts. Use other Foundry project APIs to configure hosted agents from .NET.</remarks>
    [AspireExportIgnore(Reason = "HostedAgentConfiguration callback parameters are not ATS-compatible.")]
    public static IResourceBuilder<T> AsHostedAgent<T>(
        this IResourceBuilder<T> builder, IResourceBuilder<AzureCognitiveServicesProjectResource>? project = null, Action<HostedAgentConfiguration>? configure = null)
        where T : ExecutableResource
    {
        return builder.RunAsHostedAgent(project: project, configure: configure).PublishAsHostedAgent(project: project, configure: configure);
    }
 
    /// <summary>
    /// In run mode, build, deploy, and run the containerized agent as a hosted agent in Microsoft Foundry.
    /// </summary>
    /// <remarks>This overload is not available in polyglot app hosts. Use other Foundry project APIs to configure hosted agents from .NET.</remarks>
    [AspireExportIgnore(Reason = "HostedAgentConfiguration callback parameters are not ATS-compatible.")]
    public static IResourceBuilder<T> RunAsHostedAgent<T>(
        this IResourceBuilder<T> builder, Action<HostedAgentConfiguration> configure)
        where T : ExecutableResource
    {
        return builder.RunAsHostedAgent(project: null, configure: configure);
    }
 
    /// <summary>
    /// In run mode, build, deploy, and run the containerized agent as a hosted agent in Microsoft Foundry.
    /// </summary>
    /// <remarks>This overload is not available in polyglot app hosts. Use other Foundry project APIs to configure hosted agents from .NET.</remarks>
    [AspireExportIgnore(Reason = "HostedAgentConfiguration callback parameters are not ATS-compatible.")]
    public static IResourceBuilder<T> RunAsHostedAgent<T>(
        this IResourceBuilder<T> builder, IResourceBuilder<AzureCognitiveServicesProjectResource>? project = null, Action<HostedAgentConfiguration>? configure = null)
        where T : ExecutableResource
    {
        // TODO: Implement this. This will require
        // 1. Ensuring that ACR is provisioned
        // 2. Building and pushing the container image
        // 3. Creating an agent version and returning the name/version of the agent for later use.
        throw new NotImplementedException("RunAsHostedAgent is not yet implemented.");
    }
 
    /// <summary>
    /// Publish the containerized agent as a hosted agent in Microsoft Foundry.
    ///
    /// If a project resource is not provided, the method will attempt to find an existing
    /// Azure Cognitive Services Project resource in the application model. If none exists,
    /// a new project resource (and its parent account resource) will be created automatically.
    /// </summary>
    /// <remarks>This overload is not available in polyglot app hosts. Use other Foundry project APIs to configure hosted agents from .NET.</remarks>
    [AspireExportIgnore(Reason = "HostedAgentConfiguration callback parameters are not ATS-compatible.")]
    public static IResourceBuilder<T> PublishAsHostedAgent<T>(
        this IResourceBuilder<T> builder, Action<HostedAgentConfiguration> configure)
        where T : ExecutableResource
    {
        return PublishAsHostedAgent(builder, project: null, configure: configure);
    }
 
    /// <summary>
    /// Publish the containerized agent as a hosted agent in Microsoft Foundry.
    ///
    /// If a project resource is not provided, the method will attempt to find an existing
    /// Azure Cognitive Services Project resource in the application model. If none exists,
    /// a new project resource (and its parent account resource) will be created automatically.
    /// </summary>
    /// <remarks>This overload is not available in polyglot app hosts. Use other Foundry project APIs to configure hosted agents from .NET.</remarks>
    [AspireExportIgnore(Reason = "HostedAgentConfiguration callback parameters are not ATS-compatible.")]
    public static IResourceBuilder<T> PublishAsHostedAgent<T>(
        this IResourceBuilder<T> builder, IResourceBuilder<AzureCognitiveServicesProjectResource>? project = null, Action<HostedAgentConfiguration>? configure = null)
        where T : ExecutableResource
    {
        /*
         * Much of the logic here is similar to ExecutableResourceBuilderExtensions.PublishAsDockerFile().
         *
         * That is, in Publish mode, we swap the original resource with a hosted agent resource.
         */
        ArgumentNullException.ThrowIfNull(builder);
 
        var resource = builder.Resource;
 
        if (builder.ApplicationBuilder.ExecutionContext.IsRunMode)
        {
            builder
                .WithHttpEndpoint(name: "http", env: "DEFAULT_AD_PORT", port: 8088, targetPort: 8088, isProxied: false)
                .WithUrls((ctx) =>
                {
                    var http = ctx.Urls.FirstOrDefault(u => u.Endpoint?.EndpointName == "http" || u.Endpoint?.EndpointName == "https");
                    if (http is null)
                    {
                        return;
                    }
                    ctx.Urls.Add(new()
                    {
                        DisplayText = "Responses endpoint",
                        Url = new UriBuilder(http.Url)
                        {
                            Path = "/responses"
                        }.ToString(),
                        Endpoint = http.Endpoint,
                    });
                    ctx.Urls.Add(new()
                    {
                        DisplayText = "Liveness probe",
                        Url = new UriBuilder(http.Url)
                        {
                            Path = "/liveness"
                        }.ToString(),
                        Endpoint = http.Endpoint,
                        DisplayLocation = UrlDisplayLocation.DetailsOnly
                    });
                    ctx.Urls.Add(new()
                    {
                        DisplayText = "Readiness probe",
                        Url = new UriBuilder(http.Url)
                        {
                            Path = "/readiness"
                        }.ToString(),
                        Endpoint = http.Endpoint,
                        DisplayLocation = UrlDisplayLocation.DetailsOnly
                    });
                })
                .WithHttpHealthCheck("/liveness")
                .WithHttpCommand(
                    path: "/responses",
                    displayName: "Send Message",
                    endpointName: "http",
                    commandOptions: new()
                    {
                        Method = HttpMethod.Post,
                        IconName = "Agents",
                        IconVariant = IconVariant.Regular,
                        IsHighlighted = true,
                        PrepareRequest = async ctx =>
                        {
                            var interactionService = ctx.ServiceProvider.GetRequiredService<IInteractionService>();
                            var result = await interactionService.PromptInputAsync(
                                title: "Responses API",
                                message: "Enter a message to send to the agent.",
                                inputLabel: "Message",
                                placeHolder: "I would like to know the weather today.",
                                cancellationToken: ctx.CancellationToken
                            ).ConfigureAwait(true);
                            if (result.Canceled || string.IsNullOrWhiteSpace(result.Data.Value))
                            {
                                ctx.HttpClient.CancelPendingRequests();
                                throw new OperationCanceledException("User canceled the input prompt.");
                            }
                            var request = ctx.Request;
                            var input = result.Data.Value;
                            request.Content = new StringContent(new JsonObject() { ["input"] = input }.ToString(), System.Text.Encoding.UTF8, "application/json");
                        },
                        GetCommandResult = async ctx =>
                        {
                            ctx.CancellationToken.ThrowIfCancellationRequested();
                            try
                            {
                                var response = await ctx.Response
                                    .EnsureSuccessStatusCode()
                                    .Content
                                    .ReadFromJsonAsync<JsonObject>(cancellationToken: ctx.CancellationToken)
                                    .ConfigureAwait(true);
                                var formattedResponse = $"```\n{JsonSerializer.Serialize(response, new JsonSerializerOptions { WriteIndented = true })}\n```";
                                var interactionService = ctx.ServiceProvider.GetRequiredService<IInteractionService>();
                                await interactionService.PromptMessageBoxAsync(
                                    title: "Agent Response",
                                    message: formattedResponse,
                                    options: new()
                                    {
                                        Intent = MessageIntent.Success,
                                        EnableMessageMarkdown = true,
                                        PrimaryButtonText = "Thanks!"
                                    },
                                    cancellationToken: ctx.CancellationToken
                                ).ConfigureAwait(true);
                                return new() { Success = true };
                            }
                            catch (Exception ex)
                            {
                                var interactionService = ctx.ServiceProvider.GetRequiredService<IInteractionService>();
                                await interactionService.PromptMessageBoxAsync(
                                    title: "Error",
                                    message: $"An error occurred while processing the agent's response: {ex.Message}",
                                    options: new()
                                    {
                                        Intent = MessageIntent.Error,
                                        PrimaryButtonText = "OK"
                                    },
                                    cancellationToken: ctx.CancellationToken
                                ).ConfigureAwait(true);
                                Console.Error.Write($"Error processing agent response: {ex}");
                                return new() { Success = false };
                            }
                        },
                    }
                )
                .WithOtlpExporter()
                .WithEnvironment((ctx) =>
                {
                    ctx.EnvironmentVariables.Add("OTEL_INSTRUMENTATION_OPENAI_AGENTS_ENABLED", "true");
                    ctx.EnvironmentVariables.Add("OTEL_INSTRUMENTATION_OPENAI_AGENTS_CAPTURE_CONTENT", "true");
                    ctx.EnvironmentVariables.Add("OTEL_INSTRUMENTATION_OPENAI_AGENTS_CAPTURE_METRICS", "true");
                    ctx.EnvironmentVariables.Add("OTEL_GENAI_CAPTURE_MESSAGES", "true");
                    ctx.EnvironmentVariables.Add("OTEL_GENAI_CAPTURE_SYSTEM_INSTRUCTIONS", "true");
                    ctx.EnvironmentVariables.Add("OTEL_GENAI_CAPTURE_TOOL_DEFINITIONS", "true");
                    ctx.EnvironmentVariables.Add("OTEL_GENAI_EMIT_OPERATION_DETAILS", "true");
                    ctx.EnvironmentVariables.Add("OTEL_GENAI_AGENT_NAME", ctx.Resource.Name);
                    ctx.EnvironmentVariables.Add("OTEL_GENAI_AGENT_ID", ctx.Resource.Name);
                    var endpointVar = ctx.EnvironmentVariables.FirstOrDefault((item) => item.Key == "OTEL_EXPORTER_OTLP_ENDPOINT");
                    if (endpointVar.Equals(default(KeyValuePair<string, string>)))
                    {
                        return;
                    }
                    // The Microsoft Foundry agentserver SDK expects the exporter to be at OTEL_EXPORTER_ENDPOINT instead.
                    ctx.EnvironmentVariables["OTEL_EXPORTER_ENDPOINT"] = endpointVar.Value;
                });
            return builder;
        }
        AzureCognitiveServicesProjectResource? projResource;
        if (project is not null)
        {
            projResource = project.Resource;
        }
        else
        {
            projResource = builder.ApplicationBuilder.Resources.OfType<AzureCognitiveServicesProjectResource>().FirstOrDefault();
            if (projResource is null)
            {
                project = builder.ApplicationBuilder.AddFoundryProject($"{resource.Name}-proj");
                projResource = project.Resource;
            }
            else
            {
                project = builder.ApplicationBuilder.CreateResourceBuilder(projResource);
            }
        }
        // Hosted Agent resource name
        var agentName = $"{resource.Name}-ha";
        if (builder.ApplicationBuilder.TryCreateResourceBuilder<AzureHostedAgentResource>(agentName, out var rb))
        {
            // We already have a hosted agent for this resource
            if (configure is not null)
            {
                rb.Resource.Configure = configure;
            }
            return builder;
        }
        // Get the corresponding ContainerResource. Usually this is swapped in at publish time for ExecutableResources.
        ContainerResource target;
        if (resource is ContainerResource containerResource)
        {
            target = containerResource;
        }
        else if (builder.ApplicationBuilder.TryCreateResourceBuilder<ContainerResource>(resource.Name, out var crb))
        {
            target = crb.Resource;
        }
        else
        {
            // Ensure we have a container resource to deploy
            builder.PublishAsDockerFile();
            if (builder.ApplicationBuilder.TryCreateResourceBuilder(resource.Name, out crb))
            {
                target = crb.Resource;
            }
            else
            {
                throw new InvalidOperationException($"Unable to create hosted agent for resource '{resource.Name}' because it could not be converted to a container resource.");
            }
        }
        // Create a separate agent resource to host the deployment
        var agent = new AzureHostedAgentResource(agentName, target, configure);
 
        // Ensure image gets pushed properly
        target.Annotations.Add(new DeploymentTargetAnnotation(agent)
        {
            ComputeEnvironment = projResource,
            ContainerRegistry = projResource.ContainerRegistry
        });
 
        builder.ApplicationBuilder.AddResource(agent)
            .WithReferenceRelationship(target)
            .WithReference(project);
 
        return builder;
    }
 
    /// <summary>
    /// Publish a simple prompt agent in Microsoft Foundry.
    ///
    /// If a project resource is not provided, the method will attempt to find an existing
    /// Azure Cognitive Services Project resource in the application model.
    /// </summary>
    [AspireExport("addAndPublishPromptAgent", Description = "Adds and publishes a prompt agent to a Microsoft Foundry project.")]
    public static IResourceBuilder<AzurePromptAgentResource> AddAndPublishPromptAgent(
        this IResourceBuilder<AzureCognitiveServicesProjectResource> project, IResourceBuilder<FoundryDeploymentResource> model, [ResourceName] string name, string? instructions)
    {
        ArgumentNullException.ThrowIfNull(project);
        ArgumentNullException.ThrowIfNull(model);
        var agent = new AzurePromptAgentResource(name, model.Resource.DeploymentName, instructions);
        return project.ApplicationBuilder.AddResource(agent)
            .WithReferenceRelationship(project)
            .WithArgs([
                // TODO: actually execute the prompt agent locally
                "-c",
                "--project", project.Resource.Endpoint,
                "--model", model.Resource.DeploymentName,
                "--instructions", instructions ?? string.Empty,
            ]);
    }
}