diff --git a/playground/FoundryAgentEnterprise/FoundryAgentEnterprise.AppHost/AppHost.cs b/playground/FoundryAgentEnterprise/FoundryAgentEnterprise.AppHost/AppHost.cs index 3455e4179ff..e48cdb37ae0 100644 --- a/playground/FoundryAgentEnterprise/FoundryAgentEnterprise.AppHost/AppHost.cs +++ b/playground/FoundryAgentEnterprise/FoundryAgentEnterprise.AppHost/AppHost.cs @@ -26,6 +26,7 @@ .WithReference(project) .WithReference(deployment) .WaitFor(deployment) + .AsHostedAgent() .WithComputeEnvironment(project, (opts) => { opts.Description = "Foundry Agent Basic Example"; diff --git a/playground/FoundryAgents/FoundryAgents.AppHost/AppHost.cs b/playground/FoundryAgents/FoundryAgents.AppHost/AppHost.cs index ea51a7217a2..5c6c6752696 100644 --- a/playground/FoundryAgents/FoundryAgents.AppHost/AppHost.cs +++ b/playground/FoundryAgents/FoundryAgents.AppHost/AppHost.cs @@ -44,11 +44,13 @@ builder.AddPythonApp("weather-hosted-agent", "../app", "main.py") .WithUv() .WithReference(project).WithReference(chat).WaitFor(chat) + .AsHostedAgent() .WithComputeEnvironment(project); builder.AddProject("proj-dotnet-hosted-agent") .WithHttpEndpoint(targetPort: 9000) .WithReference(project).WithReference(chat).WaitFor(chat) + .AsHostedAgent() .WithComputeEnvironment(project); // --- Prompt Agents --- diff --git a/src/Aspire.Hosting.Foundry/HostedAgent/HostedAgentBuilderExtension.cs b/src/Aspire.Hosting.Foundry/HostedAgent/HostedAgentBuilderExtension.cs index d187e221a27..eb403e77a79 100644 --- a/src/Aspire.Hosting.Foundry/HostedAgent/HostedAgentBuilderExtension.cs +++ b/src/Aspire.Hosting.Foundry/HostedAgent/HostedAgentBuilderExtension.cs @@ -17,16 +17,16 @@ public static class HostedAgentResourceBuilderExtensions { /// - /// 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 be deployed as a hosted agent in Microsoft Foundry. /// + /// The type of resource being configured. + /// The resource builder for the compute resource. + /// A callback to configure the hosted agent deployment. + /// 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 publish mode. Use + /// to configure the resource with local run-mode hosted agent endpoints, dashboard commands, + /// and OpenTelemetry settings. /// [AspireExportIgnore(Reason = "Subset of the full WithComputeEnvironment overload which is exported.")] public static IResourceBuilder WithComputeEnvironment( @@ -37,36 +37,158 @@ public static IResourceBuilder WithComputeEnvironment( } /// - /// 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 be deployed as a hosted agent in Microsoft Foundry. /// + /// The type of resource being configured. + /// The resource builder for the compute resource. + /// The Microsoft Foundry project resource to deploy the hosted agent to. + /// A callback to configure the hosted agent deployment. + /// 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. + /// + /// If is not provided, this method attempts to find an existing Microsoft Foundry + /// project resource in the application model. If none exists, a new project resource and parent account resource + /// are created automatically. + /// + /// + /// This method applies in publish mode. Use + /// to configure the resource with local run-mode hosted agent endpoints, dashboard commands, + /// and OpenTelemetry settings. + /// /// [AspireExport("withComputeEnvironmentExecutable", MethodName = "withComputeEnvironment")] public static IResourceBuilder WithComputeEnvironment( this IResourceBuilder builder, IResourceBuilder? project = null, Action? configure = 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); + if (builder.ApplicationBuilder.ExecutionContext.IsPublishMode) + { + /* + * Much of the logic here is similar to ExecutableResourceBuilderExtensions.PublishAsDockerFile(). + * + * That is, in Publish mode, we swap the original resource with a hosted agent resource. + */ + ArgumentNullException.ThrowIfNull(builder); + + var resource = builder.Resource; + + AzureCognitiveServicesProjectResource? projResource; + if (project is not null) + { + projResource = project.Resource; + } + else + { + 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); + } + } - var resource = builder.Resource; + ResourceBuilderExtensions.WithComputeEnvironment(builder, project!); + // Hosted Agent resource name + var agentName = $"{resource.Name}-ha"; + if (builder.ApplicationBuilder.TryCreateResourceBuilder(agentName, out var rb)) + { + // We already have a hosted agent for this resource + if (configure is not null) + { + rb.Resource.Configure = configure; + } + return builder; + } + // Get the corresponding ContainerResource 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)) + { + target = crb.Resource; + } + else + { + // 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(); + + 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) + { + throw new InvalidOperationException($"Unable to create hosted agent for resource '{resource.Name}' because it is not a container, executable, or project resource."); + } + else + { + target = resource; + } + } + + // Create a separate agent resource to host the deployment + var agent = new AzureHostedAgentResource(agentName, target, configure); + + // Ensure image gets pushed properly + target.Annotations.Add(new DeploymentTargetAnnotation(agent) + { + ComputeEnvironment = projResource, + ContainerRegistry = projResource.ContainerRegistry + }); + + builder.ApplicationBuilder.AddResource(agent) + .WithReferenceRelationship(target) + .WithReference(project); + } + return builder; + } + + /// + /// 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. + /// + /// 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. + /// + /// + /// + /// var agent = builder.AddProject<Projects.AgentService>("agent") + /// .AsHostedAgent(); + /// + /// + /// The resource builder. + [AspireExport] + public static IResourceBuilder AsHostedAgent(this IResourceBuilder builder) + where T : IResourceWithEndpoints, IResourceWithEnvironment, IComputeResource + { 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 existingHttpEndpoint = builder.Resource.Annotations.OfType().FirstOrDefault(e => e.Name == "http"); var targetPort = existingHttpEndpoint?.TargetPort ?? 8088; builder @@ -83,28 +205,7 @@ public static IResourceBuilder WithComputeEnvironment( { 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() - { - DisplayText = "Readiness probe", - Url = new UriBuilder(http.Url) - { - Path = "/readiness" - }.ToString(), - Endpoint = http.Endpoint, - DisplayLocation = UrlDisplayLocation.DetailsOnly - }); }) - .WithHttpHealthCheck("/liveness") .WithHttpCommand( path: "/responses", displayName: "Send Message", @@ -198,93 +299,7 @@ await interactionService.PromptMessageBoxAsync( // The Microsoft Foundry agentserver SDK expects the exporter to be at OTEL_EXPORTER_ENDPOINT instead. ctx.EnvironmentVariables["OTEL_EXPORTER_ENDPOINT"] = endpointVar.Value; }); - return builder; - } - AzureCognitiveServicesProjectResource? projResource; - if (project is not null) - { - projResource = project.Resource; - } - else - { - projResource = builder.ApplicationBuilder.Resources.OfType().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); - } - } - - ResourceBuilderExtensions.WithComputeEnvironment(builder, project!); - - // Hosted Agent resource name - var agentName = $"{resource.Name}-ha"; - if (builder.ApplicationBuilder.TryCreateResourceBuilder(agentName, out var rb)) - { - // We already have a hosted agent for this resource - if (configure is not null) - { - rb.Resource.Configure = configure; - } - return builder; } - // Get the corresponding ContainerResource 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)) - { - target = crb.Resource; - } - else - { - // 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(); - - 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) - { - throw new InvalidOperationException($"Unable to create hosted agent for resource '{resource.Name}' because it is not a container, executable, or project resource."); - } - else - { - target = resource; - } - } - - // Create a separate agent resource to host the deployment - var agent = new AzureHostedAgentResource(agentName, target, configure); - - // Ensure image gets pushed properly - target.Annotations.Add(new DeploymentTargetAnnotation(agent) - { - ComputeEnvironment = projResource, - ContainerRegistry = projResource.ContainerRegistry - }); - - builder.ApplicationBuilder.AddResource(agent) - .WithReferenceRelationship(target) - .WithReference(project); return builder; } 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..33d135b64cb 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/FoundryHostedAgentDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/FoundryHostedAgentDeploymentTests.cs @@ -244,6 +244,7 @@ static string GetRequiredConnectionValue(DbConnectionStringBuilder connectionBui builder.AddProject("dotnet-hosted-agent") .WithReference(chat).WaitFor(chat) + .AsHostedAgent() .WithComputeEnvironment(foundryProject); builder.Build().Run(); diff --git a/tests/Aspire.Hosting.Foundry.Tests/HostedAgentExtensionTests.cs b/tests/Aspire.Hosting.Foundry.Tests/HostedAgentExtensionTests.cs index 1fdee732a1a..12ef0533f24 100644 --- a/tests/Aspire.Hosting.Foundry.Tests/HostedAgentExtensionTests.cs +++ b/tests/Aspire.Hosting.Foundry.Tests/HostedAgentExtensionTests.cs @@ -12,14 +12,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 +27,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 +45,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,21 +60,21 @@ 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("Agents", command.IconName); + Assert.Equal(IconVariant.Regular, command.IconVariant); + Assert.True(command.IsHighlighted); } [Fact] diff --git a/tests/PolyglotAppHosts/Aspire.Hosting.Foundry/Go/apphost.go b/tests/PolyglotAppHosts/Aspire.Hosting.Foundry/Go/apphost.go index b6648a14dd0..1d1bfd3c8cc 100644 --- a/tests/PolyglotAppHosts/Aspire.Hosting.Foundry/Go/apphost.go +++ b/tests/PolyglotAppHosts/Aspire.Hosting.Foundry/Go/apphost.go @@ -119,6 +119,7 @@ server.listen(port, '127.0.0.1'); `, }) + hostedAgent.AsHostedAgent() hostedAgent.WithComputeEnvironment(&aspire.WithComputeEnvironmentOptions{ Project: &project, Configure: func(cfg aspire.HostedAgentConfiguration) { diff --git a/tests/PolyglotAppHosts/Aspire.Hosting.Foundry/Java/AppHost.java b/tests/PolyglotAppHosts/Aspire.Hosting.Foundry/Java/AppHost.java index 0b5fe859414..dee407ff449 100644 --- a/tests/PolyglotAppHosts/Aspire.Hosting.Foundry/Java/AppHost.java +++ b/tests/PolyglotAppHosts/Aspire.Hosting.Foundry/Java/AppHost.java @@ -106,6 +106,7 @@ void main() throws Exception { """ }); + hostedAgent.asHostedAgent(); hostedAgent.withComputeEnvironment(new WithComputeEnvironmentOptions() .project(project) .configure((configuration) -> { diff --git a/tests/PolyglotAppHosts/Aspire.Hosting.Foundry/TypeScript/apphost.mts b/tests/PolyglotAppHosts/Aspire.Hosting.Foundry/TypeScript/apphost.mts index 80ae398c9f5..a6a5bab468b 100644 --- a/tests/PolyglotAppHosts/Aspire.Hosting.Foundry/TypeScript/apphost.mts +++ b/tests/PolyglotAppHosts/Aspire.Hosting.Foundry/TypeScript/apphost.mts @@ -110,6 +110,7 @@ server.listen(port, '127.0.0.1'); ` ]); +await hostedAgent.asHostedAgent(); await hostedAgent.withComputeEnvironment({ project, configure: async (configuration) => {