diff --git a/playground/FoundryAgentEnterprise/FoundryAgentEnterprise.AppHost/AppHost.cs b/playground/FoundryAgentEnterprise/FoundryAgentEnterprise.AppHost/AppHost.cs index 3455e4179ff..8c13a616bf5 100644 --- a/playground/FoundryAgentEnterprise/FoundryAgentEnterprise.AppHost/AppHost.cs +++ b/playground/FoundryAgentEnterprise/FoundryAgentEnterprise.AppHost/AppHost.cs @@ -23,10 +23,9 @@ .WithHttpEndpoint() .WithExternalHttpEndpoints() .WithHttpHealthCheck("/health") - .WithReference(project) .WithReference(deployment) .WaitFor(deployment) - .WithComputeEnvironment(project, (opts) => + .AsHostedAgent(project, (opts) => { opts.Description = "Foundry Agent Basic Example"; opts.Metadata["managed-by"] = "aspire-foundry"; diff --git a/playground/FoundryAgents/FoundryAgents.AppHost/AppHost.cs b/playground/FoundryAgents/FoundryAgents.AppHost/AppHost.cs index ea51a7217a2..5f50030f93d 100644 --- a/playground/FoundryAgents/FoundryAgents.AppHost/AppHost.cs +++ b/playground/FoundryAgents/FoundryAgents.AppHost/AppHost.cs @@ -43,13 +43,13 @@ builder.AddPythonApp("weather-hosted-agent", "../app", "main.py") .WithUv() - .WithReference(project).WithReference(chat).WaitFor(chat) - .WithComputeEnvironment(project); + .WithReference(chat).WaitFor(chat) + .AsHostedAgent(project); builder.AddProject("proj-dotnet-hosted-agent") .WithHttpEndpoint(targetPort: 9000) - .WithReference(project).WithReference(chat).WaitFor(chat) - .WithComputeEnvironment(project); + .WithReference(chat).WaitFor(chat) + .AsHostedAgent(project); // --- Prompt Agents --- diff --git a/src/Aspire.Hosting.Foundry/FoundryExtensions.cs b/src/Aspire.Hosting.Foundry/FoundryExtensions.cs index cc4c537a51d..26d8eb009d2 100644 --- a/src/Aspire.Hosting.Foundry/FoundryExtensions.cs +++ b/src/Aspire.Hosting.Foundry/FoundryExtensions.cs @@ -41,6 +41,7 @@ public static IResourceBuilder AddFoundry(this IDistributedAppl var resource = new FoundryResource(name, ConfigureInfrastructure); return builder.AddResource(resource) + .WithIconName("AgentsAdd") .WithDefaultRoleAssignments(CognitiveServicesBuiltInRole.GetBuiltInRoleName, CognitiveServicesBuiltInRole.CognitiveServicesUser, CognitiveServicesBuiltInRole.CognitiveServicesOpenAIUser); } @@ -77,7 +78,7 @@ public static IResourceBuilder AddDeployment(this IRe deploymentBuilder.AsLocalDeployment(deployment); } - return deploymentBuilder; + return deploymentBuilder.WithIconName("BoxMultiple"); } /// diff --git a/src/Aspire.Hosting.Foundry/HostedAgent/HostedAgentBuilderExtension.cs b/src/Aspire.Hosting.Foundry/HostedAgent/HostedAgentBuilderExtension.cs index d187e221a27..4e11d6e3099 100644 --- a/src/Aspire.Hosting.Foundry/HostedAgent/HostedAgentBuilderExtension.cs +++ b/src/Aspire.Hosting.Foundry/HostedAgent/HostedAgentBuilderExtension.cs @@ -15,277 +15,332 @@ namespace Aspire.Hosting; /// public static class HostedAgentResourceBuilderExtensions { + private static readonly JsonSerializerOptions s_indentedJsonOptions = new() { WriteIndented = true }; /// - /// Configures the resource to run as a hosted agent in Microsoft Foundry. - /// - /// If a project resource is not provided, the method will attempt to find an existing - /// Microsoft Foundry project resource in the application model. If none exists, - /// a new project resource (and its parent account resource) will be created automatically. + /// Configures the resource to run locally as a Microsoft Foundry hosted agent. /// + /// Configures the resource to run locally as a Microsoft Foundry hosted agent. + /// The type of resource being configured. + /// The resource builder for the compute resource. + /// A reference to the for chaining. /// - /// In run mode, this configures the resource with hosted agent endpoints, health checks, - /// and OpenTelemetry settings. In publish mode, the resource is deployed as a hosted agent - /// in Microsoft Foundry. + /// This method applies in run mode. It configures the resource with the hosted agent responses endpoint, + /// a dashboard command for sending messages to the agent, and OpenTelemetry environment variables expected + /// by the Microsoft Foundry agent server SDK. /// - [AspireExportIgnore(Reason = "Subset of the full WithComputeEnvironment overload which is exported.")] - public static IResourceBuilder WithComputeEnvironment( - this IResourceBuilder builder, Action configure) + /// + /// + /// var agent = builder.AddProject<Projects.AgentService>("agent") + /// .AsHostedAgent(); + /// + /// + /// The resource builder. + [AspireExportIgnore(Reason = "Subset of the full AsHostedAgent(project) overload which is exported.")] + public static IResourceBuilder AsHostedAgent(this IResourceBuilder builder) where T : IResourceWithEndpoints, IResourceWithEnvironment, IComputeResource { - return WithComputeEnvironment(builder, project: null, configure: configure); + return AsHostedAgent(builder, project: null, configure: null); } + // The internal AsHostedAgentForExport overload below is the polyglot-exported version of AsHostedAgent. + // The method name differs from AsHostedAgent to avoid C# overload ambiguity with the Action-based + // overload; the polyglot-facing name is set back to "asHostedAgent" via [AspireExport(MethodName)]. + // .NET callers should keep using the Action overload above, which exposes + // the full HostedAgentConfiguration surface (tools, content filters, container protocol versions, etc.). + /// - /// Configures the resource to run as a hosted agent in Microsoft Foundry. - /// - /// If a project resource is not provided, the method will attempt to find an existing - /// Microsoft Foundry project resource in the application model. If none exists, - /// a new project resource (and its parent account resource) will be created automatically. + /// Configures the resource to run and publish as a hosted agent in Microsoft Foundry, targeting the specified Foundry project. /// - /// - /// In run mode, this configures the resource with hosted agent endpoints, health checks, - /// and OpenTelemetry settings. In publish mode, the resource is deployed as a hosted agent - /// in Microsoft Foundry. - /// - [AspireExport("withComputeEnvironmentExecutable", MethodName = "withComputeEnvironment")] - public static IResourceBuilder WithComputeEnvironment( - this IResourceBuilder builder, IResourceBuilder? project = null, Action? configure = null) + /// The type of resource being configured. + /// The resource builder for the compute resource. + /// The Microsoft Foundry project the hosted agent is deployed into. + /// Optional hosted agent deployment options (description, CPU, memory, metadata, environment variables) applied in publish mode. + /// A reference to the for chaining. + /// The resource builder. + /// Thrown when or is . + [AspireExport("asHostedAgentExecutable", MethodName = "asHostedAgent")] + internal static IResourceBuilder AsHostedAgentForExport( + this IResourceBuilder builder, + IResourceBuilder project, + HostedAgentOptions? options = null) where T : IResourceWithEndpoints, IResourceWithEnvironment, IComputeResource { - /* - * 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); + ArgumentNullException.ThrowIfNull(project); - var resource = builder.Resource; + Action? configure = options is null ? null : options.ApplyTo; + return AsHostedAgent(builder, project: project, configure: configure); + } + + /// + /// Configures the resource to run and publish as a hosted agent in Microsoft Foundry, with full programmatic + /// access to the underlying (including Azure SDK-specific options + /// such as tools and content filters). + /// + /// The type of resource being configured. + /// The resource builder for the compute resource. + /// Optional Microsoft Foundry project resource used for both run and publish mode configuration. When , an existing Foundry project in the model is reused or a new project is created in publish mode. + /// A callback to configure hosted agent deployment options in publish mode. + /// A reference to the for chaining. + [AspireExportIgnore(Reason = "Action callback shape is awkward for polyglot hosts; the HostedAgentOptions overload is exported instead.")] + public static IResourceBuilder AsHostedAgent( + this IResourceBuilder builder, + IResourceBuilder? project, + Action? configure = null) + where T : IResourceWithEndpoints, IResourceWithEnvironment, IComputeResource + { + ArgumentNullException.ThrowIfNull(builder); if (builder.ApplicationBuilder.ExecutionContext.IsRunMode) { - // Preserve any target port already configured on an existing "http" endpoint; - // fall back to the default MAF agent port (8088) when none is set. - var existingHttpEndpoint = resource.Annotations.OfType().FirstOrDefault(e => e.Name == "http"); - var targetPort = existingHttpEndpoint?.TargetPort ?? 8088; - - builder - .WithHttpEndpoint(name: "http", env: "DEFAULT_AD_PORT", targetPort: targetPort, isProxied: true) - .WithUrls((ctx) => + ConfigureRunMode(builder); + + if (project is not null) + { + AddProjectReferenceForRunMode(builder, project); + } + + return builder; + } + + var publishProject = project ?? ResolveProjectBuilderForPublish(builder); + ConfigurePublishMode(builder, publishProject, configure); + + return builder; + } + + /// + /// Configures the resource to run and publish as a hosted agent in Microsoft Foundry, with full programmatic + /// access to the underlying . The Foundry project is resolved automatically + /// in publish mode. + /// + /// The type of resource being configured. + /// The resource builder for the compute resource. + /// A callback to configure hosted agent deployment options in publish mode. + /// A reference to the for chaining. + [AspireExportIgnore(Reason = "Subset of the full AsHostedAgent overload.")] + public static IResourceBuilder AsHostedAgent( + this IResourceBuilder builder, + Action configure) + where T : IResourceWithEndpoints, IResourceWithEnvironment, IComputeResource + { + ArgumentNullException.ThrowIfNull(configure); + return AsHostedAgent(builder, project: null, configure: configure); + } + + private static void AddProjectReferenceForRunMode( + IResourceBuilder builder, + IResourceBuilder project) + where T : IResourceWithEndpoints, IResourceWithEnvironment, IComputeResource + { + builder.WithReference(project); + + // The default ACR is required for publish-time image push, but in run mode it adds noise to the dashboard. + // When a hosted agent references a Foundry project for local execution, remove the default registry resource. + if (project.Resource.DefaultContainerRegistry is { } defaultRegistry) + { + builder.ApplicationBuilder.Resources.Remove(defaultRegistry); + project.Resource.DefaultContainerRegistry = null; + } + } + + private static IResourceBuilder ResolveProjectBuilderForPublish(IResourceBuilder builder) + where T : IResourceWithEndpoints, IResourceWithEnvironment, IComputeResource + { + if (builder.ApplicationBuilder.Resources.OfType().FirstOrDefault() is { } existingProject) + { + return builder.ApplicationBuilder.CreateResourceBuilder(existingProject); + } + + return builder.ApplicationBuilder + .AddFoundry($"{builder.Resource.Name}-proj-foundry") + .AddProject($"{builder.Resource.Name}-proj"); + } + + private static void ConfigureRunMode(IResourceBuilder builder) + where T : IResourceWithEndpoints, IResourceWithEnvironment, IComputeResource + { + // Preserve any target port already configured on an existing "http" endpoint; + // fall back to the default MAF agent port (8088) when none is set. + var existingHttpEndpoint = builder.Resource.Annotations.OfType().FirstOrDefault(e => e.Name == "http"); + var targetPort = existingHttpEndpoint?.TargetPort ?? 8088; + + builder + .WithIconName("Agents") + .WithHttpEndpoint(name: "http", env: "DEFAULT_AD_PORT", targetPort: targetPort, isProxied: true) + .WithUrls((ctx) => + { + var http = ctx.Urls.FirstOrDefault(u => u.Endpoint?.EndpointName == "http" || u.Endpoint?.EndpointName == "https"); + if (http is null) { - var http = ctx.Urls.FirstOrDefault(u => u.Endpoint?.EndpointName == "http" || u.Endpoint?.EndpointName == "https"); - if (http is null) - { - return; - } - http.DisplayText = "Responses Endpoint"; - http.Url = new UriBuilder(http.Url) - { - Path = "/responses" - }.ToString(); - 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() + return; + } + http.DisplayText = "Responses Endpoint"; + http.Url = new UriBuilder(http.Url) + { + Path = "/responses" + }.ToString(); + }) + .WithHttpCommand( + path: "/responses", + displayName: "Send Message", + endpointName: "http", + commandOptions: new() + { + Method = HttpMethod.Post, + IconName = "ChatSparkle", + IconVariant = IconVariant.Regular, + IsHighlighted = true, + PrepareRequest = async ctx => { - DisplayText = "Readiness probe", - Url = new UriBuilder(http.Url) + var interactionService = ctx.ServiceProvider.GetRequiredService(); + 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)) { - Path = "/readiness" - }.ToString(), - Endpoint = http.Endpoint, - DisplayLocation = UrlDisplayLocation.DetailsOnly - }); - }) - .WithHttpHealthCheck("/liveness") - .WithHttpCommand( - path: "/responses", - displayName: "Send Message", - endpointName: "http", - commandOptions: new() + 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 => { - Method = HttpMethod.Post, - IconName = "Agents", - IconVariant = IconVariant.Regular, - IsHighlighted = true, - PrepareRequest = async ctx => + ctx.CancellationToken.ThrowIfCancellationRequested(); + + var response = ctx.Response; + if (!response.IsSuccessStatusCode) { - var interactionService = ctx.ServiceProvider.GetRequiredService(); - 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 => + var errorPayload = await response.Content.ReadAsStringAsync(ctx.CancellationToken).ConfigureAwait(true); + return CommandResults.Failure( + $"Agent request failed with status code {(int)response.StatusCode} ({response.StatusCode}).", + errorPayload, + CommandResultFormat.Text); + } + + var responseJson = await response.Content.ReadFromJsonAsync(cancellationToken: ctx.CancellationToken).ConfigureAwait(true); + if (responseJson is null) { - ctx.CancellationToken.ThrowIfCancellationRequested(); - try - { - var response = await ctx.Response - .EnsureSuccessStatusCode() - .Content - .ReadFromJsonAsync(cancellationToken: ctx.CancellationToken) - .ConfigureAwait(true); - var formattedResponse = $"```\n{JsonSerializer.Serialize(response, new JsonSerializerOptions { WriteIndented = true })}\n```"; - var interactionService = ctx.ServiceProvider.GetRequiredService(); - 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(); - 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) => + return CommandResults.Failure("Agent returned an empty response."); + } + + var formattedResponse = JsonSerializer.Serialize(responseJson, s_indentedJsonOptions); + return CommandResults.Success( + message: "Agent response received.", + result: formattedResponse, + resultFormat: CommandResultFormat.Json, + displayImmediately: true); + }, + } + ) + .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))) { - 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))) - { - 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 + return; + } + // The Microsoft Foundry agentserver SDK expects the exporter to be at OTEL_EXPORTER_ENDPOINT instead. + ctx.EnvironmentVariables["OTEL_EXPORTER_ENDPOINT"] = endpointVar.Value; + }); + } + + private static void ConfigurePublishMode( + IResourceBuilder builder, + IResourceBuilder project, + Action? configure) + where T : IResourceWithEndpoints, IResourceWithEnvironment, IComputeResource + { + /* + * 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. + */ + var resource = builder.Resource; + var projectResource = project.Resource; + + if (!projectResource.HasAnnotationOfType()) { - projResource = builder.ApplicationBuilder.Resources.OfType().FirstOrDefault(); - if (projResource is null) - { - project = builder.ApplicationBuilder - .AddFoundry($"{resource.Name}-proj-foundry") - .AddProject($"{resource.Name}-proj"); - projResource = project.Resource; - } - else - { - project = builder.ApplicationBuilder.CreateResourceBuilder(projResource); - } + projectResource.Annotations.Add(new RequiresHostedAgentRegistryAnnotation()); } - ResourceBuilderExtensions.WithComputeEnvironment(builder, project!); + ResourceBuilderExtensions.WithComputeEnvironment(builder, project); // Hosted Agent resource name var agentName = $"{resource.Name}-ha"; - if (builder.ApplicationBuilder.TryCreateResourceBuilder(agentName, out var rb)) + if (builder.ApplicationBuilder.TryCreateResourceBuilder(agentName, out var existingHostedAgent)) { // We already have a hosted agent for this resource if (configure is not null) { - rb.Resource.Configure = configure; + existingHostedAgent.Resource.Configure = configure; } - return builder; + return; } + // Get the corresponding ContainerResource for ExecutableResources. Usually this is swapped in at publish time for ExecutableResources. IResource target; if (resource is ContainerResource containerResource) { target = containerResource; } - else if (builder.ApplicationBuilder.TryCreateResourceBuilder(resource.Name, out var crb)) + else if (builder.ApplicationBuilder.TryCreateResourceBuilder(resource.Name, out var containerResourceBuilder)) { - target = crb.Resource; + target = containerResourceBuilder.Resource; } - else + else if (resource is ExecutableResource executableResource) { // Ensure we have a container resource to deploy. - // ExecutableResource needs PublishAsDockerFile() - // to convert them into container resources at this stage. - if (resource is ExecutableResource) - { - builder.ApplicationBuilder.CreateResourceBuilder((ExecutableResource)(object)resource).PublishAsDockerFile(); + // ExecutableResource needs PublishAsDockerFile() to convert it into a container resource at this stage. + builder.ApplicationBuilder.CreateResourceBuilder(executableResource).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."); - } - } - else if (resource is not ProjectResource) + if (builder.ApplicationBuilder.TryCreateResourceBuilder(resource.Name, out containerResourceBuilder)) { - throw new InvalidOperationException($"Unable to create hosted agent for resource '{resource.Name}' because it is not a container, executable, or project resource."); + target = containerResourceBuilder.Resource; } else { - target = resource; + throw new InvalidOperationException($"Unable to create hosted agent for resource '{resource.Name}' because it could not be converted to a container resource."); } } + else if (resource is ProjectResource) + { + target = resource; + } + else + { + throw new InvalidOperationException($"Unable to create hosted agent for resource '{resource.Name}' because it is not a container, executable, or project resource."); + } - // Create a separate agent resource to host the deployment - var agent = new AzureHostedAgentResource(agentName, target, configure); + // Create a separate agent resource to host the deployment. + var hostedAgent = new AzureHostedAgentResource(agentName, target, configure); - // Ensure image gets pushed properly - target.Annotations.Add(new DeploymentTargetAnnotation(agent) + // Ensure image gets pushed properly. + target.Annotations.Add(new DeploymentTargetAnnotation(hostedAgent) { - ComputeEnvironment = projResource, - ContainerRegistry = projResource.ContainerRegistry + ComputeEnvironment = projectResource, + ContainerRegistry = projectResource.ContainerRegistry }); - builder.ApplicationBuilder.AddResource(agent) + builder.ApplicationBuilder.AddResource(hostedAgent) + .WithIconName("Agents") .WithReferenceRelationship(target) .WithReference(project); - - return builder; } } diff --git a/src/Aspire.Hosting.Foundry/HostedAgent/HostedAgentOptions.cs b/src/Aspire.Hosting.Foundry/HostedAgent/HostedAgentOptions.cs new file mode 100644 index 00000000000..7cf11d3c78c --- /dev/null +++ b/src/Aspire.Hosting.Foundry/HostedAgent/HostedAgentOptions.cs @@ -0,0 +1,77 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Hosting.Foundry; + +// HostedAgentOptions exposes the subset of HostedAgentConfiguration that is meaningful to non-.NET +// app hosts. .NET callers should use the AsHostedAgent overload that takes Action +// to access the full configuration surface (tools, content filters, container protocol versions, etc.). + +/// +/// Options that control how a compute resource is deployed as a Microsoft Foundry hosted agent. +/// All properties are optional; unset properties fall back to the Foundry hosted agent defaults. +/// +[AspireDto] +internal sealed class HostedAgentOptions +{ + /// + /// Human-readable description of the hosted agent surfaced in the Microsoft Foundry portal. + /// When not set, the hosted agent default description is used. + /// + public string? Description { get; set; } + + /// + /// CPU allocation for each hosted agent instance, in vCPU cores. Must be between 0.5 and 3.5 + /// in increments of 0.25. When not set, the hosted agent default CPU allocation is used. + /// + public decimal? Cpu { get; set; } + + /// + /// Memory allocation for each hosted agent instance, in GiB. Must be between 1 and 7 in + /// increments of 0.5 and equal to twice the CPU value. When not set, the hosted agent + /// default memory allocation is used. + /// + public decimal? Memory { get; set; } + + /// + /// Additional metadata key/value pairs to attach to the hosted agent definition. + /// Entries with the same key as an existing metadata entry overwrite it. + /// + public IDictionary Metadata { get; init; } = new Dictionary(); + + /// + /// Environment variables to set on the hosted agent container at runtime. + /// Entries with the same key as an existing environment variable overwrite it. + /// + public IDictionary EnvironmentVariables { get; init; } = new Dictionary(); + + internal void ApplyTo(HostedAgentConfiguration configuration) + { + if (Description is not null) + { + configuration.Description = Description; + } + + // Cpu and Memory have a coupled invariant on HostedAgentConfiguration (Memory = Cpu * 2 with validation). + // Apply Cpu first so a subsequent Memory assignment can still override the derived value. + if (Cpu is { } cpu) + { + configuration.Cpu = cpu; + } + + if (Memory is { } memory) + { + configuration.Memory = memory; + } + + foreach (var kvp in Metadata) + { + configuration.Metadata[kvp.Key] = kvp.Value; + } + + foreach (var kvp in EnvironmentVariables) + { + configuration.EnvironmentVariables[kvp.Key] = kvp.Value; + } + } +} diff --git a/src/Aspire.Hosting.Foundry/Project/ProjectBuilderExtension.cs b/src/Aspire.Hosting.Foundry/Project/ProjectBuilderExtension.cs index 2c064f9edf7..457ed9b6239 100644 --- a/src/Aspire.Hosting.Foundry/Project/ProjectBuilderExtension.cs +++ b/src/Aspire.Hosting.Foundry/Project/ProjectBuilderExtension.cs @@ -50,7 +50,11 @@ public static IResourceBuilder AddProject builder.ApplicationBuilder.Services.Configure(o => o.SupportsTargetedRoleAssignments = true); var project = builder.ApplicationBuilder.AddResource(new AzureCognitiveServicesProjectResource(name, ConfigureInfrastructure, builder.Resource)); - project.Resource.DefaultContainerRegistry = CreateDefaultRegistry(builder.ApplicationBuilder, $"{name}-acr"); + if (builder.ApplicationBuilder.ExecutionContext.IsPublishMode) + { + project.Resource.DefaultContainerRegistry = CreateDefaultRegistry(builder.ApplicationBuilder, $"{name}-acr"); + } + return project; } @@ -324,6 +328,12 @@ public static IResourceBuilder AddModelDeployment( return builder.ApplicationBuilder.CreateResourceBuilder(builder.Resource.Parent).AddDeployment(name, modelName, modelVersion, format); } + private static bool RequiresContainerRegistryProvisioning(AzureCognitiveServicesProjectResource project) + { + return project.HasAnnotationOfType() + || project.HasAnnotationOfType(); + } + internal static void ConfigureInfrastructure(AzureResourceInfrastructure infra) { var prefix = infra.AspireResource.Name; @@ -411,44 +421,47 @@ internal static void ConfigureInfrastructure(AzureResourceInfrastructure infra) /* * Container registry for hosted agents * - * TODO: only provision if we need to create a Hosted Agent + * Only provision registry dependencies when the project will publish a hosted agent + * or when the user has explicitly supplied a registry override. */ - - AzureProvisioningResource? registry = null; - if (aspireResource.TryGetLastAnnotation(out var registryReferenceAnnotation) && registryReferenceAnnotation.Registry is AzureProvisioningResource r) - { - registry = r; - } - else if (aspireResource.DefaultContainerRegistry is not null) + if (RequiresContainerRegistryProvisioning(aspireResource)) { - registry = aspireResource.DefaultContainerRegistry; - } - else - { - throw new InvalidOperationException($"No container registry configured for Azure Cognitive Services project resource '{aspireResource.Name}'. A container registry is required to publish and run hosted agents."); + AzureProvisioningResource? registry = null; + if (aspireResource.TryGetLastAnnotation(out var registryReferenceAnnotation) && registryReferenceAnnotation.Registry is AzureProvisioningResource r) + { + registry = r; + } + else if (aspireResource.DefaultContainerRegistry is not null) + { + registry = aspireResource.DefaultContainerRegistry; + } + else + { + throw new InvalidOperationException($"No container registry configured for Azure Cognitive Services project resource '{aspireResource.Name}'. A container registry is required to publish hosted agents."); + } + + var containerRegistry = (ContainerRegistryService)registry.AddAsExistingResource(infra); + infra.Add(containerRegistry); + + // Project needs this to pull hosted agent images during hosted-agent deployment. + var pullRa = containerRegistry.CreateRoleAssignment(ContainerRegistryBuiltInRole.AcrPull, RoleManagementPrincipalType.ServicePrincipal, projectPrincipalId); + // There's a bug in the CDK, see https://github.com/Azure/azure-sdk-for-net/issues/47265 + pullRa.Name = BicepFunction.CreateGuid(containerRegistry.Id, project.Id, pullRa.RoleDefinitionId); + infra.Add(pullRa); + infra.Add(containerRegistry); + infra.Add(new ProvisioningOutput("AZURE_CONTAINER_REGISTRY_ENDPOINT", typeof(string)) + { + Value = containerRegistry.LoginServer + }); + infra.Add(new ProvisioningOutput("AZURE_CONTAINER_REGISTRY_NAME", typeof(string)) + { + Value = containerRegistry.Name + }); + infra.Add(new ProvisioningOutput("AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID", typeof(string)) + { + Value = projectPrincipalId + }); } - var containerRegistry = (ContainerRegistryService)registry.AddAsExistingResource(infra); - // Why do we need this? - infra.Add(containerRegistry); - - // Project needs this to pull hosted agent images and run them - var pullRa = containerRegistry.CreateRoleAssignment(ContainerRegistryBuiltInRole.AcrPull, RoleManagementPrincipalType.ServicePrincipal, projectPrincipalId); - // There's a bug in the CDK, see https://github.com/Azure/azure-sdk-for-net/issues/47265 - pullRa.Name = BicepFunction.CreateGuid(containerRegistry.Id, project.Id, pullRa.RoleDefinitionId); - infra.Add(pullRa); - infra.Add(containerRegistry); - infra.Add(new ProvisioningOutput("AZURE_CONTAINER_REGISTRY_ENDPOINT", typeof(string)) - { - Value = containerRegistry.LoginServer - }); - infra.Add(new ProvisioningOutput("AZURE_CONTAINER_REGISTRY_NAME", typeof(string)) - { - Value = containerRegistry.Name - }); - infra.Add(new ProvisioningOutput("AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID", typeof(string)) - { - Value = projectPrincipalId - }); // Implicit dependencies for capability hosts List capHostDeps = []; diff --git a/src/Aspire.Hosting.Foundry/Project/ProjectResource.cs b/src/Aspire.Hosting.Foundry/Project/ProjectResource.cs index 697b7cafbd8..8fcabc67d52 100644 --- a/src/Aspire.Hosting.Foundry/Project/ProjectResource.cs +++ b/src/Aspire.Hosting.Foundry/Project/ProjectResource.cs @@ -49,8 +49,9 @@ public AzureCognitiveServicesProjectResource([ResourceName] string name, Action< Description = $"Prepares Microsoft Foundry project {name} for deployment.", Action = context => { - if (this.HasAnnotationOfType() && - DefaultContainerRegistry is not null) + if (DefaultContainerRegistry is not null && + (this.HasAnnotationOfType() || + !this.HasAnnotationOfType())) { context.Model.Resources.Remove(DefaultContainerRegistry); DefaultContainerRegistry = null; @@ -248,6 +249,13 @@ public bool TryGetAppIdentityResource([NotNullWhen(true)] out IAppIdentityResour } } +/// +/// Marks a Foundry project as needing container registry provisioning for hosted agent deployment. +/// +internal sealed class RequiresHostedAgentRegistryAnnotation : IResourceAnnotation +{ +} + /// /// Configuration for a Microsoft Foundry capability host. /// diff --git a/src/Aspire.Hosting.Foundry/PromptAgent/PromptAgentBuilderExtensions.cs b/src/Aspire.Hosting.Foundry/PromptAgent/PromptAgentBuilderExtensions.cs index 0c0dc39d4ca..0ed090630ba 100644 --- a/src/Aspire.Hosting.Foundry/PromptAgent/PromptAgentBuilderExtensions.cs +++ b/src/Aspire.Hosting.Foundry/PromptAgent/PromptAgentBuilderExtensions.cs @@ -70,6 +70,7 @@ public static IResourceBuilder AddPromptAgent( var agent = new AzurePromptAgentResource(name, model.Resource.DeploymentName, project.Resource, instructions); var agentBuilder = project.ApplicationBuilder.AddResource(agent) + .WithIconName("Agents") .WithReferenceRelationship(project) .WithReference(project); @@ -103,7 +104,7 @@ public static IResourceBuilder AddPromptAgent( }, commandOptions: new() { - IconName = "Agents", + IconName = "ChatSparkle", IconVariant = IconVariant.Regular, IsHighlighted = true, Arguments = diff --git a/src/Aspire.Hosting.Foundry/README.md b/src/Aspire.Hosting.Foundry/README.md index bfe758c1b71..bb0a19056f5 100644 --- a/src/Aspire.Hosting.Foundry/README.md +++ b/src/Aspire.Hosting.Foundry/README.md @@ -163,7 +163,7 @@ var foundry = builder.AddFoundry("foundry"); var project = foundry.AddProject("my-project"); builder.AddPythonApp("agent", "./app", "main:app") - .WithComputeEnvironment(project); + .AsHostedAgent(project); ``` In run mode, the agent runs locally with health check endpoints and OpenTelemetry instrumentation. In publish mode, the agent is deployed as a hosted agent in Microsoft Foundry. diff --git a/src/Aspire.Hosting.Foundry/api/Aspire.Hosting.Foundry.ats.txt b/src/Aspire.Hosting.Foundry/api/Aspire.Hosting.Foundry.ats.txt index 2176f9296d9..a5500e67343 100644 --- a/src/Aspire.Hosting.Foundry/api/Aspire.Hosting.Foundry.ats.txt +++ b/src/Aspire.Hosting.Foundry/api/Aspire.Hosting.Foundry.ats.txt @@ -293,6 +293,8 @@ Aspire.Hosting.Foundry/HostedAgentConfiguration.metadata(context: Aspire.Hosting Aspire.Hosting.Foundry/HostedAgentConfiguration.setCpu(context: Aspire.Hosting.Foundry/Aspire.Hosting.Foundry.HostedAgentConfiguration, value: number) -> Aspire.Hosting.Foundry/Aspire.Hosting.Foundry.HostedAgentConfiguration Aspire.Hosting.Foundry/HostedAgentConfiguration.setDescription(context: Aspire.Hosting.Foundry/Aspire.Hosting.Foundry.HostedAgentConfiguration, value: string) -> Aspire.Hosting.Foundry/Aspire.Hosting.Foundry.HostedAgentConfiguration Aspire.Hosting.Foundry/HostedAgentConfiguration.setMemory(context: Aspire.Hosting.Foundry/Aspire.Hosting.Foundry.HostedAgentConfiguration, value: number) -> Aspire.Hosting.Foundry/Aspire.Hosting.Foundry.HostedAgentConfiguration +Aspire.Hosting.Foundry/asHostedAgent() -> Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithEndpoints +Aspire.Hosting.Foundry/withComputeEnvironmentExecutable(project?: Aspire.Hosting.Foundry/Aspire.Hosting.Foundry.AzureCognitiveServicesProjectResource, configure?: callback) -> Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithEndpoints Aspire.Hosting.Foundry/runAsFoundryLocal() -> Aspire.Hosting.Foundry/Aspire.Hosting.Foundry.FoundryResource Aspire.Hosting.Foundry/withAppInsights(appInsights: Aspire.Hosting.Azure.ApplicationInsights/Aspire.Hosting.Azure.AzureApplicationInsightsResource) -> Aspire.Hosting.Foundry/Aspire.Hosting.Foundry.AzureCognitiveServicesProjectResource Aspire.Hosting.Foundry/withBingReference(bingReference: Aspire.Hosting.Foundry/Aspire.Hosting.Foundry.BingGroundingConnectionResource|string|Aspire.Hosting/Aspire.Hosting.ApplicationModel.ParameterResource) -> Aspire.Hosting.Foundry/Aspire.Hosting.Foundry.BingGroundingToolResource diff --git a/src/Aspire.Hosting.Foundry/api/Aspire.Hosting.Foundry.cs b/src/Aspire.Hosting.Foundry/api/Aspire.Hosting.Foundry.cs index 750fde8134a..2889e15e269 100644 --- a/src/Aspire.Hosting.Foundry/api/Aspire.Hosting.Foundry.cs +++ b/src/Aspire.Hosting.Foundry/api/Aspire.Hosting.Foundry.cs @@ -96,6 +96,10 @@ public static ApplicationModel.IResourceBuilder WithRoleAssignments(this A public static partial class HostedAgentResourceBuilderExtensions { + [AspireExport] + public static ApplicationModel.IResourceBuilder AsHostedAgent(this ApplicationModel.IResourceBuilder builder) + where T : ApplicationModel.IResourceWithEndpoints, ApplicationModel.IResourceWithEnvironment, ApplicationModel.IComputeResource { throw null; } + [AspireExport("withComputeEnvironmentExecutable", MethodName = "withComputeEnvironment")] public static ApplicationModel.IResourceBuilder WithComputeEnvironment(this ApplicationModel.IResourceBuilder builder, ApplicationModel.IResourceBuilder? project = null, System.Action? configure = null) where T : ApplicationModel.IResourceWithEndpoints, ApplicationModel.IResourceWithEnvironment, ApplicationModel.IComputeResource { throw null; } diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/FoundryHostedAgentDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/FoundryHostedAgentDeploymentTests.cs index c912fa78e98..79aa366cecb 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/FoundryHostedAgentDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/FoundryHostedAgentDeploymentTests.cs @@ -244,7 +244,7 @@ static string GetRequiredConnectionValue(DbConnectionStringBuilder connectionBui builder.AddProject("dotnet-hosted-agent") .WithReference(chat).WaitFor(chat) - .WithComputeEnvironment(foundryProject); + .AsHostedAgent(foundryProject); builder.Build().Run(); """); diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureDeployerTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureDeployerTests.cs index ac9bb7c738e..ae26f6a456e 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureDeployerTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureDeployerTests.cs @@ -1284,7 +1284,7 @@ public async Task DeployAsync_WithFoundryAndAzureContainerApps_CreatesCorrectDep var acaEnv = builder.AddAzureContainerAppEnvironment("aca-env"); builder.AddProject("agent", launchProfileName: null) - .WithComputeEnvironment(foundryProject); + .AsHostedAgent(foundryProject); builder.AddProject("api", launchProfileName: null) .WithExternalHttpEndpoints() diff --git a/tests/Aspire.Hosting.Azure.Tests/FoundryExtensionsTests.cs b/tests/Aspire.Hosting.Azure.Tests/FoundryExtensionsTests.cs index 04e464d7bab..60af07d3359 100644 --- a/tests/Aspire.Hosting.Azure.Tests/FoundryExtensionsTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/FoundryExtensionsTests.cs @@ -195,17 +195,16 @@ public void AddProject_SetsParentFoundryForProvisioningOrdering() } [Fact] - public void AddProject_AddsDefaultContainerRegistryInRunMode() + public void AddProject_DoesNotAddDefaultContainerRegistryInRunMode() { using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); var project = builder.AddFoundry("myAIFoundry") .AddProject("my-project"); - var registry = Assert.Single(builder.Resources.OfType()); - - Assert.Equal("my-project-acr", registry.Name); - Assert.Same(registry, project.Resource.ContainerRegistry); + Assert.DoesNotContain(builder.Resources, r => r.Name == "my-project-acr"); + Assert.Empty(builder.Resources.OfType()); + Assert.Null(project.Resource.ContainerRegistry); } [Fact] @@ -295,7 +294,7 @@ public async Task WithComputeEnvironment_ResolvesExternalContainerAppReference() var advisorAgent = builder.AddProject("advisoragent", launchProfileName: null) .WithReference(weatherAgent) .WaitFor(weatherAgent) - .WithComputeEnvironment(project); + .AsHostedAgent(project); using var app = builder.Build(); await AzureManifestUtils.ExecuteBeforeStartHooksAsync(app, default); @@ -326,7 +325,7 @@ public async Task WithComputeEnvironment_DoesNotSetReservedFoundryProjectEndpoin .AddProject("my-project"); var advisorAgent = builder.AddProject("advisor-agent", launchProfileName: null) - .WithComputeEnvironment(project); + .AsHostedAgent(project); using var app = builder.Build(); await AzureManifestUtils.ExecuteBeforeStartHooksAsync(app, default); @@ -363,7 +362,7 @@ public async Task WithComputeEnvironment_ResolvesReferenceExpressionEnvironmentV { context.EnvironmentVariables["WEATHER_HEALTH_URL"] = ReferenceExpression.Create($"{weatherAgent.GetEndpoint("http")}/health"); }) - .WithComputeEnvironment(project); + .AsHostedAgent(project); using var app = builder.Build(); await AzureManifestUtils.ExecuteBeforeStartHooksAsync(app, default); @@ -403,7 +402,7 @@ public async Task WithComputeEnvironment_ResolvesEndpointReferenceExpressionEnvi { context.EnvironmentVariables["WEATHER_HOST_AND_PORT"] = weatherAgent.GetEndpoint("http").Property(EndpointProperty.HostAndPort); }) - .WithComputeEnvironment(project); + .AsHostedAgent(project); using var app = builder.Build(); await AzureManifestUtils.ExecuteBeforeStartHooksAsync(app, default); @@ -442,7 +441,7 @@ public async Task WithComputeEnvironment_ThrowsForInternalContainerAppReference( .WithReference(weatherAgent) .WaitFor(weatherAgent); - advisorAgent.WithComputeEnvironment(project); + advisorAgent.AsHostedAgent(project); using var app = builder.Build(); await AzureManifestUtils.ExecuteBeforeStartHooksAsync(app, default); @@ -469,4 +468,3 @@ private sealed class Project : IProjectMetadata } } - diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/FoundryExtensionsTests.AddProject_GeneratesEndpointFromParentFoundryApiEndpoint.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/FoundryExtensionsTests.AddProject_GeneratesEndpointFromParentFoundryApiEndpoint.verified.bicep index 355edccedb0..52fd0a673d9 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/FoundryExtensionsTests.AddProject_GeneratesEndpointFromParentFoundryApiEndpoint.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/FoundryExtensionsTests.AddProject_GeneratesEndpointFromParentFoundryApiEndpoint.verified.bicep @@ -7,8 +7,6 @@ param userPrincipalId string = '' param foundry_outputs_name string -param project_acr_outputs_name string - resource foundry 'Microsoft.CognitiveServices/accounts@2025-09-01' existing = { name: foundry_outputs_name } @@ -28,20 +26,6 @@ resource project 'Microsoft.CognitiveServices/accounts/projects@2025-09-01' = { parent: foundry } -resource project_acr 'Microsoft.ContainerRegistry/registries@2025-04-01' existing = { - name: project_acr_outputs_name -} - -resource project_acr_AcrPull 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - name: guid(project_acr.id, project.id, subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d')) - properties: { - principalId: project.identity.principalId - roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d') - principalType: 'ServicePrincipal' - } - scope: project_acr -} - resource project_ai 'Microsoft.Insights/components@2020-02-02' = { name: 'project-ai' kind: 'web' @@ -89,10 +73,4 @@ output endpoint string = project.properties.endpoints['AI Foundry API'] output principalId string = project.identity.principalId -output AZURE_CONTAINER_REGISTRY_ENDPOINT string = project_acr.properties.loginServer - -output AZURE_CONTAINER_REGISTRY_NAME string = project_acr_outputs_name - -output AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID string = project.identity.principalId - output APPLICATION_INSIGHTS_CONNECTION_STRING string = project_ai.properties.ConnectionString \ No newline at end of file diff --git a/tests/Aspire.Hosting.Foundry.Tests/HostedAgentExtensionTests.cs b/tests/Aspire.Hosting.Foundry.Tests/HostedAgentExtensionTests.cs index 1fdee732a1a..d298defd34e 100644 --- a/tests/Aspire.Hosting.Foundry.Tests/HostedAgentExtensionTests.cs +++ b/tests/Aspire.Hosting.Foundry.Tests/HostedAgentExtensionTests.cs @@ -3,6 +3,7 @@ #pragma warning disable ASPIRECOMPUTE003 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. +using System.Runtime.CompilerServices; using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Utils; using Microsoft.Extensions.DependencyInjection; @@ -12,14 +13,12 @@ namespace Aspire.Hosting.Foundry.Tests; public class HostedAgentExtensionTests { [Fact] - public void WithComputeEnvironment_InRunMode_AddsHttpEndpoint() + public void AsHostedAgent_InRunMode_AddsHttpEndpoint() { using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); - var project = builder.AddFoundry("account") - .AddProject("my-project"); var app = builder.AddPythonApp("agent", "./app.py", "main:app") - .WithComputeEnvironment(project); + .AsHostedAgent(); builder.Build(); @@ -29,15 +28,13 @@ public void WithComputeEnvironment_InRunMode_AddsHttpEndpoint() } [Fact] - public void WithComputeEnvironment_InRunMode_PreservesExistingHttpEndpointTargetPort() + public void AsHostedAgent_InRunMode_PreservesExistingHttpEndpointTargetPort() { using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); - var project = builder.AddFoundry("account") - .AddProject("my-project"); var app = builder.AddPythonApp("agent", "./app.py", "main:app") .WithHttpEndpoint(targetPort: 5000) - .WithComputeEnvironment(project); + .AsHostedAgent(); builder.Build(); @@ -49,14 +46,12 @@ public void WithComputeEnvironment_InRunMode_PreservesExistingHttpEndpointTarget } [Fact] - public void WithComputeEnvironment_InRunMode_DoesNotHardCodePort() + public void AsHostedAgent_InRunMode_DoesNotHardCodePort() { using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); - var project = builder.AddFoundry("account") - .AddProject("my-project"); var app = builder.AddPythonApp("agent", "./app.py", "main:app") - .WithComputeEnvironment(project); + .AsHostedAgent(); builder.Build(); @@ -66,25 +61,25 @@ public void WithComputeEnvironment_InRunMode_DoesNotHardCodePort() } [Fact] - public void WithComputeEnvironment_InRunMode_ConfiguresHealthCheck() + public void AsHostedAgent_InRunMode_ConfiguresSendMessageCommand() { using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); - var project = builder.AddFoundry("account") - .AddProject("my-project"); builder.AddPythonApp("agent", "./app.py", "main:app") - .WithComputeEnvironment(project); + .AsHostedAgent(); builder.Build(); - // The resource should have a health check annotation from WithHttpHealthCheck var resource = builder.Resources.Single(r => r.Name == "agent"); - var healthAnnotation = resource.Annotations.OfType().FirstOrDefault(); - Assert.NotNull(healthAnnotation); + var command = Assert.Single(resource.Annotations.OfType()); + Assert.Equal("Send Message", command.DisplayName); + Assert.Equal("ChatSparkle", command.IconName); + Assert.Equal(IconVariant.Regular, command.IconVariant); + Assert.True(command.IsHighlighted); } [Fact] - public void WithComputeEnvironment_InPublishMode_DoesNotValidateRegion() + public void AsHostedAgent_InPublishMode_DoesNotValidateRegion() { using var builder = TestDistributedApplicationBuilder.Create( DistributedApplicationOperation.Publish); @@ -95,13 +90,13 @@ public void WithComputeEnvironment_InPublishMode_DoesNotValidateRegion() .AddProject("my-project"); var app = builder.AddPythonApp("agent", "./app.py", "main:app") - .WithComputeEnvironment(project); + .AsHostedAgent(project); Assert.NotNull(app); } [Fact] - public void WithComputeEnvironment_InPublishMode_AcceptsValidRegion() + public void AsHostedAgent_InPublishMode_AcceptsValidRegion() { using var builder = TestDistributedApplicationBuilder.Create( DistributedApplicationOperation.Publish); @@ -112,33 +107,33 @@ public void WithComputeEnvironment_InPublishMode_AcceptsValidRegion() .AddProject("my-project"); var app = builder.AddPythonApp("agent", "./app.py", "main:app") - .WithComputeEnvironment(project); + .AsHostedAgent(project); Assert.NotNull(app); } [Fact] - public void WithComputeEnvironment_NoRegionConfig_DoesNotThrow() + public void AsHostedAgent_NoRegionConfig_DoesNotThrow() { using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); var project = builder.AddFoundry("account") .AddProject("my-project"); var app = builder.AddPythonApp("agent", "./app.py", "main:app") - .WithComputeEnvironment(project); + .AsHostedAgent(project); Assert.NotNull(app); } [Fact] - public void WithComputeEnvironment_InPublishMode_CreatesHostedAgentResource() + public void AsHostedAgent_InPublishMode_CreatesHostedAgentResource() { using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); var project = builder.AddFoundry("account") .AddProject("my-project"); builder.AddPythonApp("agent", "./app.py", "main:app") - .WithComputeEnvironment(project); + .AsHostedAgent(project); builder.Build(); @@ -148,12 +143,70 @@ public void WithComputeEnvironment_InPublishMode_CreatesHostedAgentResource() } [Fact] - public void WithComputeEnvironment_WithoutProject_CreatesDefaultProject() + public void AsHostedAgent_WithOptions_AppliesAllPropertiesToConfiguration() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + var project = builder.AddFoundry("account") + .AddProject("my-project"); + + var options = new HostedAgentOptions + { + Description = "test description", + Cpu = 1m, + Memory = 2m, + Metadata = { ["scenario"] = "unit-test" }, + EnvironmentVariables = { ["MY_VAR"] = "my-value" } + }; + + builder.AddPythonApp("agent", "./app.py", "main:app") + .AsHostedAgentForExport(project, options); + + builder.Build(); + + var hostedAgent = Assert.Single(builder.Resources.OfType()); + + var configuration = new HostedAgentConfiguration("test-image"); + hostedAgent.Configure!(configuration); + + Assert.Equal("test description", configuration.Description); + Assert.Equal(1m, configuration.Cpu); + Assert.Equal(2m, configuration.Memory); + Assert.Equal("unit-test", configuration.Metadata["scenario"]); + Assert.Equal("my-value", configuration.EnvironmentVariables["MY_VAR"]); + } + + [Fact] + public void AsHostedAgent_WithNullOptions_DoesNotSetConfigureCallback() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + var project = builder.AddFoundry("account") + .AddProject("my-project"); + + builder.AddPythonApp("agent", "./app.py", "main:app") + .AsHostedAgentForExport(project, options: null); + + builder.Build(); + + var hostedAgent = Assert.Single(builder.Resources.OfType()); + Assert.Null(hostedAgent.Configure); + } + + [Fact] + public void AsHostedAgent_WithNullProject_Throws() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + var app = builder.AddPythonApp("agent", "./app.py", "main:app"); + + Assert.Throws(() => app.AsHostedAgentForExport(project: null!)); + } + + [Fact] + public void AsHostedAgent_WithoutProject_CreatesDefaultProject() { using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); builder.AddPythonApp("agent", "./app.py", "main:app") - .WithComputeEnvironment(); + .AsHostedAgent(); builder.Build(); @@ -161,6 +214,52 @@ public void WithComputeEnvironment_WithoutProject_CreatesDefaultProject() Assert.NotNull(project); } + [Fact] + public void AsHostedAgent_InRunMode_WithProject_AddsProjectDependency() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); + var project = builder.AddFoundry("account") + .AddProject("my-project"); + + var app = builder.AddPythonApp("agent", "./app.py", "main:app") + .AsHostedAgent(project); + + builder.Build(); + + Assert.Contains(app.Resource.Annotations.OfType(), w => ReferenceEquals(w.Resource, project.Resource)); + } + + [Fact] + public void AsHostedAgent_InRunMode_WithProject_DoesNotCreateDefaultContainerRegistryResource() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); + var project = builder.AddFoundry("account") + .AddProject("my-project"); + + builder.AddPythonApp("agent", "./app.py", "main:app") + .AsHostedAgent(project); + + builder.Build(); + + Assert.Null(project.Resource.DefaultContainerRegistry); + Assert.DoesNotContain(builder.Resources, r => r.Name == "my-project-acr"); + } + + [Fact] + public async Task AsHostedAgent_InRunMode_WithProject_ExecutesBeforeStartHooksWithoutContainerRegistry() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); + var project = builder.AddFoundry("account") + .AddProject("my-project"); + + builder.AddPythonApp("agent", "./app.py", "main:app") + .AsHostedAgent(project); + + using var app = builder.Build(); + + await ExecuteBeforeStartHooksAsync(app, default); + } + [Fact] public async Task FoundryProject_DefaultRegistryDoesNotAddGlobalRegistryTargets() { @@ -180,4 +279,7 @@ public async Task FoundryProject_DefaultRegistryDoesNotAddGlobalRegistryTargets( var registryTarget = Assert.Single(registryTargets); Assert.Same(registry.Resource, registryTarget.Registry); } + + [UnsafeAccessor(UnsafeAccessorKind.Method, Name = "ExecuteBeforeStartHooksAsync")] + private static extern Task ExecuteBeforeStartHooksAsync(DistributedApplication app, CancellationToken cancellationToken); } diff --git a/tests/Aspire.Hosting.Foundry.Tests/ProjectResourceTests.cs b/tests/Aspire.Hosting.Foundry.Tests/ProjectResourceTests.cs index 4f98d572b19..b4407274e54 100644 --- a/tests/Aspire.Hosting.Foundry.Tests/ProjectResourceTests.cs +++ b/tests/Aspire.Hosting.Foundry.Tests/ProjectResourceTests.cs @@ -37,17 +37,17 @@ public void AddProject_ReferencesDefaultContainerRegistryForProvisioningOrdering } [Fact] - public void AddProject_InRunMode_ModelsDefaultContainerRegistry() + public void AddProject_InRunMode_DoesNotCreateDefaultContainerRegistry() { using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); var project = builder.AddFoundry("account") .AddProject("my-project"); - var registry = Assert.Single(builder.Resources.OfType()); - Assert.Equal("my-project-acr", registry.Name); - Assert.Same(project.Resource.DefaultContainerRegistry, registry); - Assert.Same(project.Resource.DefaultContainerRegistry, project.Resource.ContainerRegistry); + Assert.DoesNotContain(builder.Resources, r => r.Name == "my-project-acr"); + Assert.Empty(builder.Resources.OfType()); + Assert.Null(project.Resource.DefaultContainerRegistry); + Assert.Null(project.Resource.ContainerRegistry); } [Fact] @@ -89,6 +89,27 @@ public async Task WithAzureContainerRegistry_RemovesDefaultContainerRegistryDuri Assert.DoesNotContain(defaultRegistry, registries); } + [Fact] + public async Task AddProject_WithoutHostedAgents_RemovesDefaultContainerRegistryDuringBeforeStart() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var project = builder.AddFoundry("account") + .AddProject("my-project"); + + var defaultRegistry = project.Resource.DefaultContainerRegistry; + Assert.NotNull(defaultRegistry); + Assert.Contains(defaultRegistry, builder.Resources); + + using var app = builder.Build(); + await ExecuteBeforeStartHooksAsync(app, default); + + var model = app.Services.GetRequiredService(); + var registries = model.Resources.OfType().ToList(); + Assert.DoesNotContain(defaultRegistry, registries); + Assert.Null(project.Resource.DefaultContainerRegistry); + } + [Fact] public void ConnectionStringExpression_HasCorrectFormat() { diff --git a/tests/Aspire.Hosting.Foundry.Tests/PromptAgentTests.cs b/tests/Aspire.Hosting.Foundry.Tests/PromptAgentTests.cs index cc1983bfdb1..d36a4593adc 100644 --- a/tests/Aspire.Hosting.Foundry.Tests/PromptAgentTests.cs +++ b/tests/Aspire.Hosting.Foundry.Tests/PromptAgentTests.cs @@ -55,6 +55,26 @@ public void AddPromptAgent_SetsProjectReference() Assert.Same(project.Resource, agent.Resource.Project); } + [Fact] + public void AddPromptAgent_ConfiguresSendMessageCommand() + { + using var builder = TestDistributedApplicationBuilder.Create(); + var project = builder.AddFoundry("account") + .AddProject("my-project"); + var model = project.AddModelDeployment("gpt41", FoundryModel.OpenAI.Gpt41); + + project.AddPromptAgent("my-agent", model); + + builder.Build(); + + var resource = builder.Resources.Single(r => r.Name == "my-agent"); + var command = Assert.Single(resource.Annotations.OfType()); + Assert.Equal("Send Message", command.DisplayName); + Assert.Equal("ChatSparkle", command.IconName); + Assert.Equal(IconVariant.Regular, command.IconVariant); + Assert.True(command.IsHighlighted); + } + [Fact] public void AddPromptAgent_WithNullName_Throws() { diff --git a/tests/PolyglotAppHosts/Aspire.Hosting.Foundry/Go/apphost.go b/tests/PolyglotAppHosts/Aspire.Hosting.Foundry/Go/apphost.go index b6648a14dd0..c87454c2faf 100644 --- a/tests/PolyglotAppHosts/Aspire.Hosting.Foundry/Go/apphost.go +++ b/tests/PolyglotAppHosts/Aspire.Hosting.Foundry/Go/apphost.go @@ -119,14 +119,17 @@ server.listen(port, '127.0.0.1'); `, }) - hostedAgent.WithComputeEnvironment(&aspire.WithComputeEnvironmentOptions{ - Project: &project, - Configure: func(cfg aspire.HostedAgentConfiguration) { - cfg.SetDescription("Validation hosted agent") - cfg.SetCpu(1) - cfg.SetMemory(2) - _ = cfg.Metadata().Set("scenario", "validation") - _ = cfg.EnvironmentVariables().Set("VALIDATION_MODE", "true") + hostedAgent.AsHostedAgent(project, &aspire.AsHostedAgentOptions{ + Options: &aspire.HostedAgentOptions{ + Description: "Validation hosted agent", + Cpu: aspire.Float64Ptr(1), + Memory: aspire.Float64Ptr(2), + Metadata: map[string]string{ + "scenario": "validation", + }, + EnvironmentVariables: map[string]string{ + "VALIDATION_MODE": "true", + }, }, }) diff --git a/tests/PolyglotAppHosts/Aspire.Hosting.Foundry/Java/AppHost.java b/tests/PolyglotAppHosts/Aspire.Hosting.Foundry/Java/AppHost.java index 0b5fe859414..58171b2d41b 100644 --- a/tests/PolyglotAppHosts/Aspire.Hosting.Foundry/Java/AppHost.java +++ b/tests/PolyglotAppHosts/Aspire.Hosting.Foundry/Java/AppHost.java @@ -1,4 +1,5 @@ import aspire.*; +import java.util.Map; void main() throws Exception { var builder = DistributedApplication.CreateBuilder(); @@ -106,15 +107,13 @@ void main() throws Exception { """ }); - hostedAgent.withComputeEnvironment(new WithComputeEnvironmentOptions() - .project(project) - .configure((configuration) -> { - configuration.setDescription("Validation hosted agent"); - configuration.setCpu(1); - configuration.setMemory(2); - configuration.metadata().put("scenario", "validation"); - configuration.environmentVariables().put("VALIDATION_MODE", "true"); - })); + var hostedAgentOptions = new HostedAgentOptions(); + hostedAgentOptions.setDescription("Validation hosted agent"); + hostedAgentOptions.setCpu(1.0); + hostedAgentOptions.setMemory(2.0); + hostedAgentOptions.setMetadata(Map.of("scenario", "validation")); + hostedAgentOptions.setEnvironmentVariables(Map.of("VALIDATION_MODE", "true")); + hostedAgent.asHostedAgent(project, hostedAgentOptions); var api = builder.addContainer("api", "nginx"); foundry.withContainerRegistryRoleAssignments(registry, new AzureContainerRegistryRole[] { diff --git a/tests/PolyglotAppHosts/Aspire.Hosting.Foundry/Python/apphost.py b/tests/PolyglotAppHosts/Aspire.Hosting.Foundry/Python/apphost.py index 840e9da9d17..5b5d7a61277 100644 --- a/tests/PolyglotAppHosts/Aspire.Hosting.Foundry/Python/apphost.py +++ b/tests/PolyglotAppHosts/Aspire.Hosting.Foundry/Python/apphost.py @@ -123,7 +123,7 @@ """ ]) - hosted_agent.with_compute_environment(project=project) + hosted_agent.as_hosted_agent(project=project) api = builder.add_container("api", "nginx") foundry.with_container_registry_role_assignments(registry) diff --git a/tests/PolyglotAppHosts/Aspire.Hosting.Foundry/TypeScript/apphost.mts b/tests/PolyglotAppHosts/Aspire.Hosting.Foundry/TypeScript/apphost.mts index 80ae398c9f5..2479a1aeba8 100644 --- a/tests/PolyglotAppHosts/Aspire.Hosting.Foundry/TypeScript/apphost.mts +++ b/tests/PolyglotAppHosts/Aspire.Hosting.Foundry/TypeScript/apphost.mts @@ -110,17 +110,12 @@ server.listen(port, '127.0.0.1'); ` ]); -await hostedAgent.withComputeEnvironment({ - project, - configure: async (configuration) => { - await configuration.description.set('Validation hosted agent'); - await configuration.cpu.set(1); - await configuration.memory.set(2); - const metadata = await configuration.metadata(); - await metadata.set('scenario', 'validation'); - const environmentVariables = await configuration.environmentVariables(); - await environmentVariables.set('VALIDATION_MODE', 'true'); - } +await hostedAgent.asHostedAgent(project, { + description: 'Validation hosted agent', + cpu: 1, + memory: 2, + metadata: { scenario: 'validation' }, + environmentVariables: { VALIDATION_MODE: 'true' } }); const api = await builder.addContainer('api', 'nginx');