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..5120af20ebf 100644 --- a/src/Aspire.Hosting.Foundry/api/Aspire.Hosting.Foundry.ats.txt +++ b/src/Aspire.Hosting.Foundry/api/Aspire.Hosting.Foundry.ats.txt @@ -27,6 +27,12 @@ Aspire.Hosting.Foundry/Aspire.Hosting.Foundry.FoundryModel # Describes a model t Format: string # The format or provider of the model (e.g., OpenAI, Microsoft, xAi, Deepseek). Name: string # The name of the model. Version: string # The version of the model. +Aspire.Hosting.Foundry/Aspire.Hosting.Foundry.HostedAgentOptions # 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. + Cpu?: number # 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. + Description: string # Human-readable description of the hosted agent surfaced in the Microsoft Foundry portal. When not set, the hosted agent default description is used. + EnvironmentVariables?: Aspire.Hosting/Dict # Environment variables to set on the hosted agent container at runtime. Entries with the same key as an existing environment variable overwrite it. + Memory?: number # 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. + Metadata?: Aspire.Hosting/Dict # Additional metadata key/value pairs to attach to the hosted agent definition. Entries with the same key as an existing metadata entry overwrite it. # Enum Types enum:Aspire.Hosting.FoundryRole = CognitiveServicesOpenAIContributor | CognitiveServicesOpenAIUser | CognitiveServicesUser @@ -262,6 +268,7 @@ Aspire.Hosting.Foundry/addSearchConnection(search: Aspire.Hosting.Azure.Search/A Aspire.Hosting.Foundry/addSharePointTool(name: string, projectConnectionIds: string[]) -> Aspire.Hosting.Foundry/Aspire.Hosting.Foundry.SharePointToolResource Aspire.Hosting.Foundry/addStorageConnection(storage: Aspire.Hosting.Azure.Storage/Aspire.Hosting.Azure.AzureStorageResource) -> Aspire.Hosting.Foundry/Aspire.Hosting.Foundry.AzureCognitiveServicesProjectConnectionResource Aspire.Hosting.Foundry/addWebSearchTool(name: string) -> Aspire.Hosting.Foundry/Aspire.Hosting.Foundry.WebSearchToolResource +Aspire.Hosting.Foundry/asHostedAgentExecutable(project: Aspire.Hosting.Foundry/Aspire.Hosting.Foundry.AzureCognitiveServicesProjectResource, options?: Aspire.Hosting.Foundry/Aspire.Hosting.Foundry.HostedAgentOptions) -> Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithEndpoints Aspire.Hosting.Foundry/AzurePromptAgentResource.connectionStringExpression(context: Aspire.Hosting.Foundry/Aspire.Hosting.Foundry.AzurePromptAgentResource) -> Aspire.Hosting/Aspire.Hosting.ApplicationModel.ReferenceExpression Aspire.Hosting.Foundry/AzurePromptAgentResource.description(context: Aspire.Hosting.Foundry/Aspire.Hosting.Foundry.AzurePromptAgentResource) -> string Aspire.Hosting.Foundry/AzurePromptAgentResource.instructions(context: Aspire.Hosting.Foundry/Aspire.Hosting.Foundry.AzurePromptAgentResource) -> string @@ -297,7 +304,6 @@ Aspire.Hosting.Foundry/runAsFoundryLocal() -> Aspire.Hosting.Foundry/Aspire.Host 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 Aspire.Hosting.Foundry/withCapabilityHost(resource: Aspire.Hosting.Azure.CosmosDB/Aspire.Hosting.AzureCosmosDBResource|Aspire.Hosting.Azure.Storage/Aspire.Hosting.Azure.AzureStorageResource|Aspire.Hosting.Azure.Search/Aspire.Hosting.Azure.AzureSearchResource|Aspire.Hosting.Foundry/Aspire.Hosting.Foundry.FoundryResource) -> Aspire.Hosting.Foundry/Aspire.Hosting.Foundry.AzureCognitiveServicesProjectResource -Aspire.Hosting.Foundry/withComputeEnvironmentExecutable(project?: Aspire.Hosting.Foundry/Aspire.Hosting.Foundry.AzureCognitiveServicesProjectResource, configure?: callback) -> Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithEndpoints Aspire.Hosting.Foundry/withFoundryDeploymentProperties(configure: callback) -> Aspire.Hosting.Foundry/Aspire.Hosting.Foundry.FoundryDeploymentResource Aspire.Hosting.Foundry/withFoundryRoleAssignments(target: Aspire.Hosting.Foundry/Aspire.Hosting.Foundry.FoundryResource, roles: enum:Aspire.Hosting.FoundryRole[]) -> Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResource Aspire.Hosting.Foundry/withKeyVault(keyVault: Aspire.Hosting.Azure.KeyVault/Aspire.Hosting.Azure.AzureKeyVaultResource) -> Aspire.Hosting.Foundry/Aspire.Hosting.Foundry.AzureCognitiveServicesProjectResource diff --git a/src/Aspire.Hosting.Foundry/api/Aspire.Hosting.Foundry.cs b/src/Aspire.Hosting.Foundry/api/Aspire.Hosting.Foundry.cs index 750fde8134a..430e6376e72 100644 --- a/src/Aspire.Hosting.Foundry/api/Aspire.Hosting.Foundry.cs +++ b/src/Aspire.Hosting.Foundry/api/Aspire.Hosting.Foundry.cs @@ -10,31 +10,31 @@ namespace Aspire.Hosting { public static partial class AzureCognitiveServicesProjectConnectionsBuilderExtensions { - [AspireExport("addBingGroundingConnectionFromParameter", Description = "Adds a Grounding with Bing Search connection to a Microsoft Foundry project using a parameter.")] + [AspireExport("addBingGroundingConnectionFromParameter")] public static ApplicationModel.IResourceBuilder AddBingGroundingConnection(this ApplicationModel.IResourceBuilder builder, string name, ApplicationModel.IResourceBuilder bingResourceId) { throw null; } - [AspireExport(Description = "Adds a Grounding with Bing Search connection to a Microsoft Foundry project.")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddBingGroundingConnection(this ApplicationModel.IResourceBuilder builder, string name, string bingResourceId) { throw null; } - [AspireExport("addContainerRegistryConnection", Description = "Adds an Azure Container Registry connection to a Microsoft Foundry project.")] + [AspireExport("addContainerRegistryConnection")] public static ApplicationModel.IResourceBuilder AddConnection(this ApplicationModel.IResourceBuilder builder, ApplicationModel.IResourceBuilder registry) { throw null; } - [AspireExport("addKeyVaultConnection", Description = "Adds an Azure Key Vault connection to a Microsoft Foundry project.")] + [AspireExport("addKeyVaultConnection")] public static ApplicationModel.IResourceBuilder AddConnection(this ApplicationModel.IResourceBuilder builder, ApplicationModel.IResourceBuilder keyVault) { throw null; } - [AspireExport("addSearchConnection", Description = "Adds an Azure AI Search connection to a Microsoft Foundry project.")] + [AspireExport("addSearchConnection")] public static ApplicationModel.IResourceBuilder AddConnection(this ApplicationModel.IResourceBuilder builder, ApplicationModel.IResourceBuilder search) { throw null; } - [AspireExport("addStorageConnection", Description = "Adds an Azure Storage connection to a Microsoft Foundry project.")] + [AspireExport("addStorageConnection")] public static ApplicationModel.IResourceBuilder AddConnection(this ApplicationModel.IResourceBuilder builder, ApplicationModel.IResourceBuilder storage) { throw null; } - [AspireExport("addCosmosConnection", Description = "Adds an Azure Cosmos DB connection to a Microsoft Foundry project.")] + [AspireExport("addCosmosConnection")] public static ApplicationModel.IResourceBuilder AddConnection(this ApplicationModel.IResourceBuilder builder, ApplicationModel.IResourceBuilder db) { throw null; } [AspireExportIgnore(Reason = "Raw AzureContainerRegistryResource parameters are not ATS-compatible. Use the resource-builder overload instead.")] public static ApplicationModel.IResourceBuilder AddConnection(this ApplicationModel.IResourceBuilder builder, Azure.AzureContainerRegistryResource registry) { throw null; } - [AspireExport("addSearchConnectionFromResource", Description = "Adds an Azure AI Search connection to a Microsoft Foundry project.")] + [AspireExportIgnore(Reason = "Raw AzureSearchResource parameters are not ATS-compatible. Use the resource-builder overload instead.")] public static ApplicationModel.IResourceBuilder AddConnection(this ApplicationModel.IResourceBuilder builder, Azure.AzureSearchResource search) { throw null; } [AspireExportIgnore(Reason = "Raw AzureStorageResource parameters are not ATS-compatible. Use the resource-builder overload instead.")] @@ -58,13 +58,13 @@ public static partial class AzureCognitiveServicesProjectExtensions [AspireExportIgnore(Reason = "Polyglot app hosts use the internal addModelDeployment dispatcher export.")] public static ApplicationModel.IResourceBuilder AddModelDeployment(this ApplicationModel.IResourceBuilder builder, string name, string modelName, string modelVersion, string format) { throw null; } - [AspireExport(Description = "Adds a Microsoft Foundry project resource to a Microsoft Foundry resource.")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddProject(this ApplicationModel.IResourceBuilder builder, string name) { throw null; } - [AspireExport(Description = "Associates an Azure Application Insights resource with a Microsoft Foundry project.")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithAppInsights(this ApplicationModel.IResourceBuilder builder, ApplicationModel.IResourceBuilder appInsights) { throw null; } - [AspireExport(Description = "Associates an Azure Key Vault resource with a Microsoft Foundry project.")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithKeyVault(this ApplicationModel.IResourceBuilder builder, ApplicationModel.IResourceBuilder keyVault) { throw null; } [AspireExportIgnore(Reason = "The standard WithReference export already covers this polyglot scenario.")] @@ -80,13 +80,13 @@ public static partial class FoundryExtensions [AspireExportIgnore(Reason = "Polyglot app hosts use the internal addDeployment dispatcher export.")] public static ApplicationModel.IResourceBuilder AddDeployment(this ApplicationModel.IResourceBuilder builder, string name, string modelName, string modelVersion, string format) { throw null; } - [AspireExport(Description = "Adds a Microsoft Foundry resource to the distributed application model.")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddFoundry(this IDistributedApplicationBuilder builder, string name) { throw null; } - [AspireExport(Description = "Configures the Microsoft Foundry resource to run by using Foundry Local.")] + [AspireExport] public static ApplicationModel.IResourceBuilder RunAsFoundryLocal(this ApplicationModel.IResourceBuilder builder) { throw null; } - [AspireExport("withFoundryDeploymentProperties", MethodName = "withProperties", Description = "Configures properties of a Microsoft Foundry deployment resource.", RunSyncOnBackgroundThread = true)] + [AspireExport("withFoundryDeploymentProperties", MethodName = "withProperties", RunSyncOnBackgroundThread = true)] public static ApplicationModel.IResourceBuilder WithProperties(this ApplicationModel.IResourceBuilder builder, System.Action configure) { throw null; } [AspireExportIgnore(Reason = "CognitiveServicesBuiltInRole is an Azure.Provisioning type not compatible with ATS. Use the FoundryRole-based overload instead.")] @@ -96,60 +96,64 @@ public static ApplicationModel.IResourceBuilder WithRoleAssignments(this A public static partial class HostedAgentResourceBuilderExtensions { - [AspireExport("withComputeEnvironmentExecutable", MethodName = "withComputeEnvironment")] - public static ApplicationModel.IResourceBuilder WithComputeEnvironment(this ApplicationModel.IResourceBuilder builder, ApplicationModel.IResourceBuilder? project = null, System.Action? configure = null) + [AspireExportIgnore(Reason = "Action callback shape is awkward for polyglot hosts; the HostedAgentOptions overload is exported instead.")] + public static ApplicationModel.IResourceBuilder AsHostedAgent(this ApplicationModel.IResourceBuilder builder, ApplicationModel.IResourceBuilder? project, System.Action? configure = null) where T : ApplicationModel.IResourceWithEndpoints, ApplicationModel.IResourceWithEnvironment, ApplicationModel.IComputeResource { throw null; } - [AspireExportIgnore(Reason = "Subset of the full WithComputeEnvironment overload which is exported.")] - public static ApplicationModel.IResourceBuilder WithComputeEnvironment(this ApplicationModel.IResourceBuilder builder, System.Action configure) + [AspireExportIgnore(Reason = "Subset of the full AsHostedAgent overload.")] + public static ApplicationModel.IResourceBuilder AsHostedAgent(this ApplicationModel.IResourceBuilder builder, System.Action configure) + where T : ApplicationModel.IResourceWithEndpoints, ApplicationModel.IResourceWithEnvironment, ApplicationModel.IComputeResource { throw null; } + + [AspireExportIgnore(Reason = "Subset of the full AsHostedAgent(project) overload which is exported.")] + public static ApplicationModel.IResourceBuilder AsHostedAgent(this ApplicationModel.IResourceBuilder builder) where T : ApplicationModel.IResourceWithEndpoints, ApplicationModel.IResourceWithEnvironment, ApplicationModel.IComputeResource { throw null; } } public static partial class PromptAgentBuilderExtensions { - [AspireExport(Description = "Adds an Azure AI Search tool to a Microsoft Foundry project.")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddAISearchTool(this ApplicationModel.IResourceBuilder project, string name, string? indexName = null) { throw null; } [AspireExportIgnore(Reason = "BinaryData parameter is not ATS-compatible. Use the string overload instead.")] public static ApplicationModel.IResourceBuilder AddAzureFunctionTool(this ApplicationModel.IResourceBuilder project, string name, string functionName, string description, System.BinaryData parameters, string inputQueueEndpoint, string inputQueueName, string outputQueueEndpoint, string outputQueueName) { throw null; } - [AspireExport(Description = "Adds an Azure Function tool to a Microsoft Foundry project.")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddAzureFunctionTool(this ApplicationModel.IResourceBuilder project, string name, string functionName, string description, string parametersJson, string inputQueueEndpoint, string inputQueueName, string outputQueueEndpoint, string outputQueueName) { throw null; } - [AspireExport(Description = "Adds a Bing Grounding tool to a Microsoft Foundry project.")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddBingGroundingTool(this ApplicationModel.IResourceBuilder project, string name) { throw null; } - [AspireExport(Description = "Adds a Code Interpreter tool to a Microsoft Foundry project.")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddCodeInterpreterTool(this ApplicationModel.IResourceBuilder project, string name) { throw null; } - [AspireExport(Description = "Adds a Computer Use tool to a Microsoft Foundry project.")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddComputerUseTool(this ApplicationModel.IResourceBuilder project, string name, int displayWidth = 1024, int displayHeight = 768, string environment = "browser") { throw null; } - [AspireExport(Description = "Adds a Microsoft Fabric data agent tool to a Microsoft Foundry project.")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddFabricTool(this ApplicationModel.IResourceBuilder project, string name, params string[] projectConnectionIds) { throw null; } - [AspireExport(Description = "Adds a File Search tool to a Microsoft Foundry project.")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddFileSearchTool(this ApplicationModel.IResourceBuilder project, string name, params string[] vectorStoreIds) { throw null; } [AspireExportIgnore(Reason = "BinaryData parameter is not ATS-compatible. Use the string overload instead.")] public static ApplicationModel.IResourceBuilder AddFunctionTool(this ApplicationModel.IResourceBuilder project, string name, string functionName, System.BinaryData parameters, string? description = null, bool? strictModeEnabled = null) { throw null; } - [AspireExport(Description = "Adds an Image Generation tool to a Microsoft Foundry project.")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddImageGenerationTool(this ApplicationModel.IResourceBuilder project, string name) { throw null; } - [AspireExport(Description = "Adds a prompt agent to a Microsoft Foundry project.")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddPromptAgent(this ApplicationModel.IResourceBuilder project, string name, ApplicationModel.IResourceBuilder model, string? instructions = null) { throw null; } - [AspireExport(Description = "Adds a SharePoint grounding tool to a Microsoft Foundry project.")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddSharePointTool(this ApplicationModel.IResourceBuilder project, string name, params string[] projectConnectionIds) { throw null; } - [AspireExport(Description = "Adds a Web Search tool to a Microsoft Foundry project.")] + [AspireExport] public static ApplicationModel.IResourceBuilder AddWebSearchTool(this ApplicationModel.IResourceBuilder project, string name) { throw null; } [AspireExportIgnore(Reason = "IFoundryTool is not ATS-compatible.")] public static ApplicationModel.IResourceBuilder WithCustomTool(this ApplicationModel.IResourceBuilder builder, Foundry.IFoundryTool tool) { throw null; } - [AspireExport(Description = "Links an Azure AI Search tool to a backing search resource.")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithReference(this ApplicationModel.IResourceBuilder tool, ApplicationModel.IResourceBuilder search) { throw null; } [AspireExportIgnore(Reason = "Covered by the internal AspireUnion overload.")] @@ -161,7 +165,7 @@ public static partial class PromptAgentBuilderExtensions [AspireExportIgnore(Reason = "Covered by the internal AspireUnion overload.")] public static ApplicationModel.IResourceBuilder WithReference(this ApplicationModel.IResourceBuilder tool, string bingResourceId) { throw null; } - [AspireExport(Description = "Adds a tool to a prompt agent.")] + [AspireExport] public static ApplicationModel.IResourceBuilder WithTool(this ApplicationModel.IResourceBuilder agent, ApplicationModel.IResourceBuilder tool) { throw null; } } } @@ -435,14 +439,6 @@ public partial class FoundryModel public required string Version { get { throw null; } init { } } - public static partial class AI21Labs - { - [AspireValue("FoundryModels")] - public static readonly FoundryModel AI21Jamba15Large; - [AspireValue("FoundryModels")] - public static readonly FoundryModel AI21Jamba15Mini; - } - public static partial class Anthropic { [AspireValue("FoundryModels")] @@ -480,12 +476,8 @@ public static partial class Cohere [AspireValue("FoundryModels")] public static readonly FoundryModel CohereCommandA; [AspireValue("FoundryModels")] - public static readonly FoundryModel CohereCommandR; - [AspireValue("FoundryModels")] public static readonly FoundryModel CohereCommandR082024; [AspireValue("FoundryModels")] - public static readonly FoundryModel CohereCommandRPlus; - [AspireValue("FoundryModels")] public static readonly FoundryModel CohereCommandRPlus082024; [AspireValue("FoundryModels")] public static readonly FoundryModel CohereEmbedV3English; @@ -517,11 +509,13 @@ public static partial class DeepSeek [AspireValue("FoundryModels")] public static readonly FoundryModel DeepSeekV30324; [AspireValue("FoundryModels")] - public static readonly FoundryModel DeepSeekV31; - [AspireValue("FoundryModels")] public static readonly FoundryModel DeepSeekV32; [AspireValue("FoundryModels")] public static readonly FoundryModel DeepSeekV32Speciale; + [AspireValue("FoundryModels")] + public static readonly FoundryModel DeepSeekV4Flash; + [AspireValue("FoundryModels")] + public static readonly FoundryModel DeepSeekV4Pro; } public static partial class Local @@ -537,8 +531,14 @@ public static partial class Local [AspireValue("FoundryModels")] public static readonly FoundryModel Mistral7bV02; [AspireValue("FoundryModels")] + public static readonly FoundryModel MistralNemo12bInstruct; + [AspireValue("FoundryModels")] public static readonly FoundryModel NemotronSpeechStreamingEn06b; [AspireValue("FoundryModels")] + public static readonly FoundryModel NemotronSpeechStreamingEs06b; + [AspireValue("FoundryModels")] + public static readonly FoundryModel Olmo37bInstruct; + [AspireValue("FoundryModels")] public static readonly FoundryModel Phi35Mini; [AspireValue("FoundryModels")] public static readonly FoundryModel Phi3Mini128k; @@ -582,6 +582,8 @@ public static partial class Local [AspireValue("FoundryModels")] public static readonly FoundryModel Qwen352b; [AspireValue("FoundryModels")] + public static readonly FoundryModel Qwen352bText; + [AspireValue("FoundryModels")] public static readonly FoundryModel Qwen354b; [AspireValue("FoundryModels")] public static readonly FoundryModel Qwen359b; @@ -597,6 +599,8 @@ public static partial class Local public static readonly FoundryModel Qwen3Vl4bInstruct; [AspireValue("FoundryModels")] public static readonly FoundryModel Qwen3Vl8bInstruct; + [AspireValue("FoundryModels")] + public static readonly FoundryModel Smollm33b; } public static partial class Meta @@ -606,21 +610,13 @@ public static partial class Meta [AspireValue("FoundryModels")] public static readonly FoundryModel Llama3290BVisionInstruct; [AspireValue("FoundryModels")] - public static readonly FoundryModel Llama3370BInstruct; - [AspireValue("FoundryModels")] public static readonly FoundryModel Llama4Maverick17B128EInstructFP8; [AspireValue("FoundryModels")] public static readonly FoundryModel Llama4Scout17B16EInstruct; [AspireValue("FoundryModels")] public static readonly FoundryModel MetaLlama31405BInstruct; [AspireValue("FoundryModels")] - public static readonly FoundryModel MetaLlama3170BInstruct; - [AspireValue("FoundryModels")] public static readonly FoundryModel MetaLlama318BInstruct; - [AspireValue("FoundryModels")] - public static readonly FoundryModel MetaLlama370BInstruct; - [AspireValue("FoundryModels")] - public static readonly FoundryModel MetaLlama38BInstruct; } public static partial class Microsoft @@ -646,8 +642,12 @@ public static partial class Microsoft [AspireValue("FoundryModels")] public static readonly FoundryModel AzureLanguageConversationalPiiRedaction; [AspireValue("FoundryModels")] + public static readonly FoundryModel AzureLanguageDocumentPiiRedaction; + [AspireValue("FoundryModels")] public static readonly FoundryModel AzureLanguageLanguageDetection; [AspireValue("FoundryModels")] + public static readonly FoundryModel AzureLanguageTextAnalyticsForHealth; + [AspireValue("FoundryModels")] public static readonly FoundryModel AzureLanguageTextPiiRedaction; [AspireValue("FoundryModels")] public static readonly FoundryModel AzureSpeechSpeechToText; @@ -693,6 +693,8 @@ public static partial class Microsoft [AspireValue("FoundryModels")] public static readonly FoundryModel Phi3Small8kInstruct; [AspireValue("FoundryModels")] + public static readonly FoundryModel Phi3Vision128kInstruct; + [AspireValue("FoundryModels")] public static readonly FoundryModel Phi4; [AspireValue("FoundryModels")] public static readonly FoundryModel Phi4MiniInstruct; @@ -718,18 +720,10 @@ public static partial class MistralAI [AspireValue("FoundryModels")] public static readonly FoundryModel MistralDocumentAi2512; [AspireValue("FoundryModels")] - public static readonly FoundryModel MistralLarge2407; - [AspireValue("FoundryModels")] - public static readonly FoundryModel MistralLarge2411; - [AspireValue("FoundryModels")] public static readonly FoundryModel MistralLarge3; [AspireValue("FoundryModels")] public static readonly FoundryModel MistralMedium2505; [AspireValue("FoundryModels")] - public static readonly FoundryModel MistralNemo; - [AspireValue("FoundryModels")] - public static readonly FoundryModel MistralSmall; - [AspireValue("FoundryModels")] public static readonly FoundryModel MistralSmall2503; } @@ -830,6 +824,8 @@ public static partial class OpenAI [AspireValue("FoundryModels")] public static readonly FoundryModel GptAudioMini; [AspireValue("FoundryModels")] + public static readonly FoundryModel GptChatLatest; + [AspireValue("FoundryModels")] public static readonly FoundryModel GptImage1; [AspireValue("FoundryModels")] public static readonly FoundryModel GptImage15; @@ -840,14 +836,18 @@ public static partial class OpenAI [AspireValue("FoundryModels")] public static readonly FoundryModel GptOss120b; [AspireValue("FoundryModels")] - public static readonly FoundryModel GptOss20b; - [AspireValue("FoundryModels")] public static readonly FoundryModel GptRealtime; [AspireValue("FoundryModels")] public static readonly FoundryModel GptRealtime15; [AspireValue("FoundryModels")] + public static readonly FoundryModel GptRealtime2; + [AspireValue("FoundryModels")] public static readonly FoundryModel GptRealtimeMini; [AspireValue("FoundryModels")] + public static readonly FoundryModel GptRealtimeTranslate; + [AspireValue("FoundryModels")] + public static readonly FoundryModel GptRealtimeWhisper; + [AspireValue("FoundryModels")] public static readonly FoundryModel O1; [AspireValue("FoundryModels")] public static readonly FoundryModel O1Mini; @@ -891,10 +891,6 @@ public static partial class StabilityAI public static partial class XAI { - [AspireValue("FoundryModels")] - public static readonly FoundryModel Grok3; - [AspireValue("FoundryModels")] - public static readonly FoundryModel Grok3Mini; [AspireValue("FoundryModels")] public static readonly FoundryModel Grok4; [AspireValue("FoundryModels")] @@ -906,6 +902,8 @@ public static partial class XAI [AspireValue("FoundryModels")] public static readonly FoundryModel Grok420Reasoning; [AspireValue("FoundryModels")] + public static readonly FoundryModel Grok43; + [AspireValue("FoundryModels")] public static readonly FoundryModel Grok4FastNonReasoning; [AspireValue("FoundryModels")] public static readonly FoundryModel Grok4FastReasoning; @@ -1007,8 +1005,6 @@ public HostedAgentConfiguration(string image) { } [AspireExportIgnore(Reason = "Azure SDK-specific type not usable from polyglot hosts.")] public System.Collections.Generic.IList Tools { get { throw null; } init { } } - - public global::Azure.AI.Projects.Agents.ProjectsAgentVersionCreationOptions ToProjectsAgentVersionCreationOptions() { throw null; } } public partial interface IFoundryTool diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/FoundryHostedAgentDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/FoundryHostedAgentDeploymentTests.cs index 99f58098d1f..a961dda924b 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/FoundryHostedAgentDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/FoundryHostedAgentDeploymentTests.cs @@ -210,7 +210,7 @@ private async Task DeployFoundryHostedAgentToAzureCore(CancellationToken cancell 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');