diff --git a/Aspire.slnx b/Aspire.slnx index f5dd278ff44..c903dfedba8 100644 --- a/Aspire.slnx +++ b/Aspire.slnx @@ -228,6 +228,9 @@ + + + diff --git a/Directory.Packages.props b/Directory.Packages.props index cae9f6bf7aa..ab7f0ee2cce 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -12,6 +12,7 @@ + @@ -105,6 +106,7 @@ + @@ -117,7 +119,7 @@ - + diff --git a/playground/FoundryHostedAgents/DotNetHostedAgent/.dockerignore b/playground/FoundryHostedAgents/DotNetHostedAgent/.dockerignore new file mode 100644 index 00000000000..a6fc51cb8e2 --- /dev/null +++ b/playground/FoundryHostedAgents/DotNetHostedAgent/.dockerignore @@ -0,0 +1,24 @@ +**/.dockerignore +**/.env +**/.git +**/.gitignore +**/.project +**/.settings +**/.toolstarget +**/.vs +**/.vscode +**/*.*proj.user +**/*.dbmdl +**/*.jfm +**/azds.yaml +**/bin +**/charts +**/docker-compose* +**/Dockerfile* +**/node_modules +**/npm-debug.log +**/obj +**/secrets.dev.yaml +**/values.dev.yaml +LICENSE +README.md \ No newline at end of file diff --git a/playground/FoundryHostedAgents/DotNetHostedAgent/DotNetHostedAgent.csproj b/playground/FoundryHostedAgents/DotNetHostedAgent/DotNetHostedAgent.csproj new file mode 100644 index 00000000000..e7a07884f0a --- /dev/null +++ b/playground/FoundryHostedAgents/DotNetHostedAgent/DotNetHostedAgent.csproj @@ -0,0 +1,33 @@ + + + + Exe + $(DefaultTargetFramework) + enable + enable + true + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/playground/FoundryHostedAgents/DotNetHostedAgent/Program.cs b/playground/FoundryHostedAgents/DotNetHostedAgent/Program.cs new file mode 100644 index 00000000000..953d4330c87 --- /dev/null +++ b/playground/FoundryHostedAgents/DotNetHostedAgent/Program.cs @@ -0,0 +1,90 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.ComponentModel; +using System.Data.Common; +using Azure.AI.AgentServer.AgentFramework.Extensions; +using Azure.AI.OpenAI; +using Azure.Identity; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; + +string chatConnectionString = Environment.GetEnvironmentVariable("ConnectionStrings__chat") + ?? throw new InvalidOperationException("ConnectionStrings__chat is not set."); + +DbConnectionStringBuilder chatConnectionBuilder = new() +{ + ConnectionString = chatConnectionString, +}; + +string endpoint = GetRequiredConnectionValue(chatConnectionBuilder, "Endpoint"); +string deploymentName = GetRequiredConnectionValue(chatConnectionBuilder, "Deployment"); + +if (!Uri.TryCreate(endpoint, UriKind.Absolute, out Uri? openAiEndpoint) || openAiEndpoint is null) +{ + throw new InvalidOperationException("ConnectionStrings__chat contains an invalid Endpoint value."); +} + +Console.WriteLine($"OpenAI Endpoint: {openAiEndpoint}"); +Console.WriteLine($"Model Deployment: {deploymentName}"); + +// Read the port from environment variable (set by Aspire), default to 8088 +string? portString = Environment.GetEnvironmentVariable("DEFAULT_AD_PORT"); +int port = int.TryParse(portString, out int parsedPort) ? parsedPort : 8088; + +[Description("Get a weather forecast")] +WeatherForecast[]? GetWeatherForecast() +{ + string[] summaries = ["Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"]; + var forecast = Enumerable.Range(1, 5).Select(index => + new WeatherForecast + ( + DateOnly.FromDateTime(DateTime.Now.AddDays(index)), + Random.Shared.Next(-20, 55), + summaries[Random.Shared.Next(summaries.Length)] + )) + .ToArray(); + return forecast; +} + +DefaultAzureCredential credential = new(); + +IChatClient chatClient = new AzureOpenAIClient(openAiEndpoint, credential) + .GetChatClient(deploymentName) + .AsIChatClient() + .AsBuilder() + .UseOpenTelemetry(sourceName: "Agents", configure: cfg => cfg.EnableSensitiveData = true) + .Build(); + +AIAgent agent = chatClient.AsAIAgent( + name: "WeatherAgent", + instructions: """You are the Weather Intelligence Agent that can return weather forecast using your tools.""", + tools: [AIFunctionFactory.Create(GetWeatherForecast)]) + .AsBuilder() + .UseOpenTelemetry(sourceName: "Agents", configure: cfg => cfg.EnableSensitiveData = true) + .Build(); + +Console.WriteLine($"Weather Agent Server running on http://localhost:{port}"); +await agent.RunAIAgentAsync(telemetrySourceName: "Agents"); + +string GetRequiredConnectionValue(DbConnectionStringBuilder connectionBuilder, string key) +{ + if (!connectionBuilder.TryGetValue(key, out object? rawValue) || rawValue is null) + { + throw new InvalidOperationException($"ConnectionStrings__chat is missing '{key}'."); + } + + string? value = rawValue.ToString(); + + if (string.IsNullOrWhiteSpace(value)) + { + throw new InvalidOperationException($"ConnectionStrings__chat has an empty '{key}' value."); + } + + return value; +} + +internal sealed record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary) +{ + public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); +} diff --git a/playground/FoundryHostedAgents/FoundryHostedAgents.AppHost/AppHost.cs b/playground/FoundryHostedAgents/FoundryHostedAgents.AppHost/AppHost.cs new file mode 100644 index 00000000000..2abbf23567c --- /dev/null +++ b/playground/FoundryHostedAgents/FoundryHostedAgents.AppHost/AppHost.cs @@ -0,0 +1,22 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.Foundry; + +var builder = DistributedApplication.CreateBuilder(args); + +var foundry = builder.AddFoundry("aif-myfoundry"); +var project = foundry.AddProject("proj-myproject"); +var chat = project.AddModelDeployment("chat", FoundryModel.OpenAI.Gpt41); + +builder.AddPythonApp("weather-hosted-agent", "../app", "main.py") + .WithUv() + .WithReference(chat).WaitFor(chat) + .PublishAsHostedAgent(project); + +builder.AddProject("proj-dotnet-hosted-agent") + .WithEndpoint("http", e => e.TargetPort = 9000) + .WithReference(chat).WaitFor(chat) + .PublishAsHostedAgent(project); + +builder.Build().Run(); diff --git a/playground/FoundryHostedAgents/FoundryHostedAgents.AppHost/FoundryHostedAgents.AppHost.csproj b/playground/FoundryHostedAgents/FoundryHostedAgents.AppHost/FoundryHostedAgents.AppHost.csproj new file mode 100644 index 00000000000..c95037975ce --- /dev/null +++ b/playground/FoundryHostedAgents/FoundryHostedAgents.AppHost/FoundryHostedAgents.AppHost.csproj @@ -0,0 +1,24 @@ + + + + Exe + $(DefaultTargetFramework) + enable + enable + true + 232bfcff-4739-4857-9b6f-6d7681cb0980 + + + + + + + + + + + + + + + diff --git a/playground/FoundryHostedAgents/FoundryHostedAgents.AppHost/Properties/launchSettings.json b/playground/FoundryHostedAgents/FoundryHostedAgents.AppHost/Properties/launchSettings.json new file mode 100644 index 00000000000..25eef89e157 --- /dev/null +++ b/playground/FoundryHostedAgents/FoundryHostedAgents.AppHost/Properties/launchSettings.json @@ -0,0 +1,40 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:17145;http://localhost:15099", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21011", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22200" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:15099", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19103", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20134", + "ASPIRE_ALLOW_UNSECURED_TRANSPORT": "true" + } + }, + "generate-manifest": { + "commandName": "Project", + "launchBrowser": true, + "dotnetRunMessages": true, + "commandLineArgs": "--publisher manifest --output-path aspire-manifest.json", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development" + } + } + } +} diff --git a/playground/FoundryHostedAgents/FoundryHostedAgents.AppHost/appsettings.json b/playground/FoundryHostedAgents/FoundryHostedAgents.AppHost/appsettings.json new file mode 100644 index 00000000000..31c092aa450 --- /dev/null +++ b/playground/FoundryHostedAgents/FoundryHostedAgents.AppHost/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Aspire.Hosting.Dcp": "Warning" + } + } +} diff --git a/playground/FoundryHostedAgents/app/.dockerignore b/playground/FoundryHostedAgents/app/.dockerignore new file mode 100644 index 00000000000..d9d15981fd9 --- /dev/null +++ b/playground/FoundryHostedAgents/app/.dockerignore @@ -0,0 +1,7 @@ +# Byte-compiled / optimized / DLL files +**/__pycache__/ +**/*.py[cod] + +# Virtual environment +.env +.venv/ diff --git a/playground/FoundryHostedAgents/app/main.py b/playground/FoundryHostedAgents/app/main.py new file mode 100644 index 00000000000..c13b0e08fa9 --- /dev/null +++ b/playground/FoundryHostedAgents/app/main.py @@ -0,0 +1,63 @@ +import asyncio +import datetime +import json +import os +import random + +# Microsoft Agent Framework +from agent_framework import Agent, tool +from agent_framework.azure import AzureOpenAIChatClient +from azure.ai.agentserver.agentframework import from_agent_framework +from azure.identity import DefaultAzureCredential + +@tool(name="get_forecast", description="Get a weather forecast") +async def get_forecast() -> str: + try: + summaries = [ + "Freezing", + "Bracing", + "Chilly", + "Cool", + "Mild", + "Warm", + "Balmy", + "Hot", + "Sweltering", + "Scorching", + ] + + forecast = [] + for index in range(1, 6): # Range 1 to 5 (inclusive) + temp_c = random.randint(-20, 55) + forecast_date = datetime.datetime.now() + datetime.timedelta(days=index) + forecast_item = { + "date": forecast_date.isoformat(), + "temperatureC": temp_c, + "temperatureF": int(temp_c * 9 / 5) + 32, + "summary": random.choice(summaries), + } + forecast.append(forecast_item) + + return json.dumps(forecast, indent=2) + except Exception as e: + return json.dumps({"error": str(e)}) + +async def main(): + """Main function to run the agent as a web server.""" + + # client = FoundryChatClient(project_endpoint=os.getenv("CHAT_URI"), credential=AzureCliCredential(), model="chat") + agent = AzureOpenAIChatClient(endpoint=os.getenv("CHAT_URI"), credential=DefaultAzureCredential(), deployment_name="chat").as_agent( + # client = client, + name="weather-agent", + instructions="""You are the Weather Intelligence Agent that can return weather forecast using your tools.""", + tools=[get_forecast], + ) + + + app = from_agent_framework(agent) + + await app.run_async() + + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/playground/FoundryHostedAgents/app/pyproject.toml b/playground/FoundryHostedAgents/app/pyproject.toml new file mode 100644 index 00000000000..0e984d66233 --- /dev/null +++ b/playground/FoundryHostedAgents/app/pyproject.toml @@ -0,0 +1,14 @@ +[project] +name = "weather-agent-python" +version = "0.1.0" +description = "Weather intelligence agent for AlpineAI ski resort" +requires-python = ">=3.11" +dependencies = [ + "fastapi>=0.104.1", + "agent-framework", + "azure-ai-agentserver-agentframework>=1.0.0b17", + "azure-identity", +] + +[tool.uv] +prerelease = "allow" \ No newline at end of file diff --git a/playground/FoundryHostedAgents/aspire.config.json b/playground/FoundryHostedAgents/aspire.config.json new file mode 100644 index 00000000000..4de3d3d9935 --- /dev/null +++ b/playground/FoundryHostedAgents/aspire.config.json @@ -0,0 +1,5 @@ +{ + "appHost": { + "path": "FoundryHostedAgents.AppHost/FoundryHostedAgents.AppHost.csproj" + } +} \ No newline at end of file diff --git a/src/Aspire.Hosting.Foundry/HostedAgent/HostedAgentBuilderExtension.cs b/src/Aspire.Hosting.Foundry/HostedAgent/HostedAgentBuilderExtension.cs index 1794dbb52c0..85432d6ab94 100644 --- a/src/Aspire.Hosting.Foundry/HostedAgent/HostedAgentBuilderExtension.cs +++ b/src/Aspire.Hosting.Foundry/HostedAgent/HostedAgentBuilderExtension.cs @@ -77,7 +77,7 @@ public static IResourceBuilder RunAsHostedAgent( [AspireExportIgnore(Reason = "Subset of the full PublishAsHostedAgent overload which is exported.")] public static IResourceBuilder PublishAsHostedAgent( this IResourceBuilder builder, Action configure) - where T : ExecutableResource + where T : IResourceWithEndpoints, IResourceWithEnvironment { return PublishAsHostedAgent(builder, project: null, configure: configure); } @@ -92,7 +92,7 @@ public static IResourceBuilder PublishAsHostedAgent( [AspireExport("publishAsHostedAgentExecutable", MethodName = "publishAsHostedAgent", Description = "Publishes an executable resource as a hosted agent in Microsoft Foundry.")] public static IResourceBuilder PublishAsHostedAgent( this IResourceBuilder builder, IResourceBuilder? project = null, Action? configure = null) - where T : ExecutableResource + where T : IResourceWithEndpoints, IResourceWithEnvironment { /* * Much of the logic here is similar to ExecutableResourceBuilderExtensions.PublishAsDockerFile(). @@ -105,8 +105,13 @@ public static IResourceBuilder PublishAsHostedAgent( 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", port: 8088, targetPort: 8088, isProxied: false) + .WithHttpEndpoint(name: "http", env: "DEFAULT_AD_PORT", targetPort: targetPort) .WithUrls((ctx) => { var http = ctx.Urls.FirstOrDefault(u => u.Endpoint?.EndpointName == "http" || u.Endpoint?.EndpointName == "https"); @@ -283,8 +288,18 @@ await interactionService.PromptMessageBoxAsync( } else { - // Ensure we have a container resource to deploy - builder.PublishAsDockerFile(); + // Ensure we have a container resource to deploy. + // ProjectResource is automatically published as a container, so only + // ExecutableResource needs PublishAsDockerFile(). + if (resource is ExecutableResource) + { + builder.ApplicationBuilder.CreateResourceBuilder((ExecutableResource)(object)resource).PublishAsDockerFile(); + } + 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."); + } + if (builder.ApplicationBuilder.TryCreateResourceBuilder(resource.Name, out crb)) { target = crb.Resource; diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/FoundryHostedAgentDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/FoundryHostedAgentDeploymentTests.cs new file mode 100644 index 00000000000..ccf0994fa25 --- /dev/null +++ b/tests/Aspire.Deployment.EndToEnd.Tests/FoundryHostedAgentDeploymentTests.cs @@ -0,0 +1,335 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Cli.Resources; +using Aspire.Cli.Tests.Utils; +using Aspire.Deployment.EndToEnd.Tests.Helpers; +using Hex1b.Automation; +using Xunit; + +namespace Aspire.Deployment.EndToEnd.Tests; + +/// +/// End-to-end tests for deploying Aspire applications with Foundry Hosted Agents. +/// +public sealed class FoundryHostedAgentDeploymentTests(ITestOutputHelper output) +{ + // Timeout set to 45 minutes to allow for Azure AI Foundry provisioning and model deployment. + // Foundry deployments can take longer than standard ACA due to AI resource provisioning. + private static readonly TimeSpan s_testTimeout = TimeSpan.FromMinutes(45); + + [Fact] + public async Task DeployFoundryHostedAgentToAzure() + { + using var cts = new CancellationTokenSource(s_testTimeout); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( + cts.Token, TestContext.Current.CancellationToken); + var cancellationToken = linkedCts.Token; + + await DeployFoundryHostedAgentToAzureCore(cancellationToken); + } + + private async Task DeployFoundryHostedAgentToAzureCore(CancellationToken cancellationToken) + { + // Validate prerequisites + var subscriptionId = AzureAuthenticationHelpers.TryGetSubscriptionId(); + if (string.IsNullOrEmpty(subscriptionId)) + { + Assert.Skip("Azure subscription not configured. Set ASPIRE_DEPLOYMENT_TEST_SUBSCRIPTION."); + } + + if (!AzureAuthenticationHelpers.IsAzureAuthAvailable()) + { + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + Assert.Fail("Azure authentication not available in CI. Check OIDC configuration."); + } + else + { + Assert.Skip("Azure authentication not available. Run 'az login' to authenticate."); + } + } + + var workspace = TemporaryWorkspace.Create(output); + var startTime = DateTime.UtcNow; + var deploymentUrls = new Dictionary(); + // Generate a unique resource group name with pattern: e2e-[testcasename]-[runid]-[attempt] + var resourceGroupName = DeploymentE2ETestHelpers.GenerateResourceGroupName("foundry-agent"); + var projectName = "FoundryAgent"; + + output.WriteLine($"Test: {nameof(DeployFoundryHostedAgentToAzure)}"); + output.WriteLine($"Project Name: {projectName}"); + output.WriteLine($"Resource Group: {resourceGroupName}"); + output.WriteLine($"Subscription: {subscriptionId[..8]}..."); + output.WriteLine($"Workspace: {workspace.WorkspaceRoot.FullName}"); + + try + { + using var terminal = DeploymentE2ETestHelpers.CreateTestTerminal(); + var pendingRun = terminal.RunAsync(cancellationToken); + + var counter = new SequenceCounter(); + var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + + // Step 1: Prepare environment + output.WriteLine("Step 1: Preparing environment..."); + await auto.PrepareEnvironmentAsync(workspace, counter); + + // Step 2: Set up CLI environment (in CI) + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + output.WriteLine("Step 2: Using pre-installed Aspire CLI from local build..."); + await auto.SourceAspireCliEnvironmentAsync(counter); + } + + // Step 3: Create starter project using aspire new (for basic AppHost scaffold) + output.WriteLine("Step 3: Creating starter project..."); + await auto.AspireNewAsync(projectName, counter, useRedisCache: false); + + // Step 4: Navigate to project directory + output.WriteLine("Step 4: Navigating to project directory..."); + await auto.TypeAsync($"cd {projectName}"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter); + + // Step 5: Add Aspire.Hosting.Foundry package to the AppHost + output.WriteLine("Step 5: Adding Foundry hosting package..."); + await auto.TypeAsync("aspire add Aspire.Hosting.Foundry"); + await auto.EnterAsync(); + + // In CI, aspire add shows a version selection prompt + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + await auto.WaitUntilTextAsync("(based on NuGet.config)", timeout: TimeSpan.FromSeconds(60)); + await auto.EnterAsync(); // select first version (PR build) + } + + await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(180)); + + // Step 6: Create a dedicated .NET hosted agent project + // PublishAsHostedAgent requires a proper agent application, not a standard apiservice. + output.WriteLine("Step 6: Creating .NET hosted agent project..."); + var projectDir = Path.Combine(workspace.WorkspaceRoot.FullName, projectName); + var hostedAgentDir = Path.Combine(projectDir, "DotNetHostedAgent"); + var appHostDir = Path.Combine(projectDir, $"{projectName}.AppHost"); + + Directory.CreateDirectory(hostedAgentDir); + + // Write minimal hosted agent .csproj + // The project is created outside the repo, so it has no central package management. + // Explicit versions are required, matching those from the playground DotNetHostedAgent. + File.WriteAllText(Path.Combine(hostedAgentDir, "DotNetHostedAgent.csproj"), """ + + + Exe + net10.0 + enable + enable + true + + + + + + + + + + + + """); + + // Write minimal hosted agent Program.cs + // This follows the same pattern as the playground DotNetHostedAgent: + // reads ConnectionStrings__chat, creates an AI agent, and runs on DEFAULT_AD_PORT. + File.WriteAllText(Path.Combine(hostedAgentDir, "Program.cs"), """ + using System.ComponentModel; + using System.Data.Common; + using Azure.AI.AgentServer.AgentFramework.Extensions; + using Azure.AI.OpenAI; + using Azure.Identity; + using Microsoft.Agents.AI; + using Microsoft.Extensions.AI; + + string chatConnectionString = Environment.GetEnvironmentVariable("ConnectionStrings__chat") + ?? throw new InvalidOperationException("ConnectionStrings__chat is not set."); + + DbConnectionStringBuilder chatConnectionBuilder = new() + { + ConnectionString = chatConnectionString, + }; + + string endpoint = chatConnectionBuilder.TryGetValue("Endpoint", out object? epValue) ? epValue?.ToString()! : throw new InvalidOperationException("Missing Endpoint."); + string deploymentName = chatConnectionBuilder.TryGetValue("Deployment", out object? dnValue) ? dnValue?.ToString()! : throw new InvalidOperationException("Missing Deployment."); + + Uri openAiEndpoint = new(endpoint); + + [Description("Get a weather forecast")] + string GetWeatherForecast() => "Sunny, 25°C"; + + DefaultAzureCredential credential = new(); + + IChatClient chatClient = new AzureOpenAIClient(openAiEndpoint, credential) + .GetChatClient(deploymentName) + .AsIChatClient(); + + AIAgent agent = chatClient.AsAIAgent( + name: "WeatherAgent", + instructions: "You are the Weather Intelligence Agent.", + tools: [AIFunctionFactory.Create(GetWeatherForecast)]); + + await agent.RunAIAgentAsync(telemetrySourceName: "Agents"); + """); + + // Write Dockerfile for the hosted agent + File.WriteAllText(Path.Combine(hostedAgentDir, ".dockerignore"), """ + bin/ + obj/ + """); + + output.WriteLine($"Created hosted agent project at: {hostedAgentDir}"); + + // Step 7: Add hosted agent to the solution and add project reference from AppHost + output.WriteLine("Step 7: Adding hosted agent to solution..."); + await auto.TypeAsync($"dotnet sln add DotNetHostedAgent/DotNetHostedAgent.csproj"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(30)); + + await auto.TypeAsync($"dotnet add {projectName}.AppHost/{projectName}.AppHost.csproj reference DotNetHostedAgent/DotNetHostedAgent.csproj"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(30)); + + // Step 8: Modify AppHost.cs to wire up Foundry + hosted agent + // Replace the standard starter template AppHost with Foundry-based configuration. + var appHostFilePath = Path.Combine(appHostDir, "AppHost.cs"); + output.WriteLine($"Modifying AppHost.cs at: {appHostFilePath}"); + + var appHostContent = File.ReadAllText(appHostFilePath); + + // Add the Foundry using directive + appHostContent = "using Aspire.Hosting.Foundry;\n" + appHostContent; + + // Insert Foundry resources before builder.Build().Run(); + appHostContent = appHostContent.Replace( + "builder.Build().Run();", + """ + var foundry = builder.AddFoundry("aif-myfoundry"); + var foundryProject = foundry.AddProject("proj-myproject"); + var chat = foundryProject.AddModelDeployment("chat", FoundryModel.OpenAI.Gpt41); + + builder.AddProject("dotnet-hosted-agent") + .WithReference(chat).WaitFor(chat) + .PublishAsHostedAgent(foundryProject); + + builder.Build().Run(); + """); + + File.WriteAllText(appHostFilePath, appHostContent); + output.WriteLine($"Modified AppHost.cs at: {appHostFilePath}"); + + // Step 9: Navigate to AppHost project directory + output.WriteLine("Step 9: Navigating to AppHost directory..."); + await auto.TypeAsync($"cd {projectName}.AppHost"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter); + + // Step 10: Set environment variables for deployment + // - Unset ASPIRE_PLAYGROUND to avoid conflicts + // - Set Azure location + // - Set AZURE__RESOURCEGROUP to use our unique resource group name + await auto.TypeAsync($"unset ASPIRE_PLAYGROUND && export AZURE__LOCATION=westus3 && export AZURE__RESOURCEGROUP={resourceGroupName}"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter); + + // Step 11: Deploy to Azure using aspire deploy + output.WriteLine("Step 11: Starting Foundry Hosted Agent deployment..."); + await auto.TypeAsync("aspire deploy --clear-cache"); + await auto.EnterAsync(); + // Wait for pipeline to complete successfully + // Foundry deployments may take longer due to AI resource provisioning + await auto.WaitUntilTextAsync(ConsoleActivityLoggerStrings.PipelineSucceeded, timeout: TimeSpan.FromMinutes(35)); + await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2)); + + // Step 12: Verify deployed resources exist in the resource group + output.WriteLine("Step 12: Verifying deployed resources..."); + await auto.TypeAsync( + $"RG_NAME=\"{resourceGroupName}\" && " + + "echo \"Resource group: $RG_NAME\" && " + + "if ! az group show -n \"$RG_NAME\" &>/dev/null; then echo \"❌ Resource group not found\"; exit 1; fi && " + + "resources=$(az resource list -g \"$RG_NAME\" -o table 2>/dev/null) && " + + "echo \"$resources\" && " + + "if [ -z \"$resources\" ]; then echo \"❌ No resources found in resource group\"; exit 1; fi && " + + "echo \"✅ Resources found in resource group\""); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2)); + + // Step 13: Exit terminal + await auto.TypeAsync("exit"); + await auto.EnterAsync(); + + await pendingRun; + + var duration = DateTime.UtcNow - startTime; + output.WriteLine($"Deployment completed in {duration}"); + + // Report success + DeploymentReporter.ReportDeploymentSuccess( + nameof(DeployFoundryHostedAgentToAzure), + resourceGroupName, + deploymentUrls, + duration); + + output.WriteLine("✅ Test passed!"); + } + catch (Exception ex) + { + var duration = DateTime.UtcNow - startTime; + output.WriteLine($"❌ Test failed after {duration}: {ex.Message}"); + + DeploymentReporter.ReportDeploymentFailure( + nameof(DeployFoundryHostedAgentToAzure), + resourceGroupName, + ex.Message, + ex.StackTrace); + + throw; + } + finally + { + // Clean up the resource group we created + output.WriteLine($"Triggering cleanup of resource group: {resourceGroupName}"); + TriggerCleanupResourceGroup(resourceGroupName, output); + DeploymentReporter.ReportCleanupStatus(resourceGroupName, success: true, "Cleanup triggered (fire-and-forget)"); + } + } + + /// + /// Triggers cleanup of a specific resource group. + /// This is fire-and-forget - the hourly cleanup workflow handles any missed resources. + /// + private static void TriggerCleanupResourceGroup(string resourceGroupName, ITestOutputHelper output) + { + var process = new System.Diagnostics.Process + { + StartInfo = new System.Diagnostics.ProcessStartInfo + { + FileName = "az", + Arguments = $"group delete --name {resourceGroupName} --yes --no-wait", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + } + }; + + try + { + process.Start(); + output.WriteLine($"Cleanup triggered for resource group: {resourceGroupName}"); + } + catch (Exception ex) + { + output.WriteLine($"Failed to trigger cleanup: {ex.Message}"); + } + } +} diff --git a/tests/Aspire.Hosting.Foundry.Tests/HostedAgentExtensionTests.cs b/tests/Aspire.Hosting.Foundry.Tests/HostedAgentExtensionTests.cs index 069c3bc861b..155d30b2e1f 100644 --- a/tests/Aspire.Hosting.Foundry.Tests/HostedAgentExtensionTests.cs +++ b/tests/Aspire.Hosting.Foundry.Tests/HostedAgentExtensionTests.cs @@ -28,6 +28,43 @@ public void PublishAsHostedAgent_InRunMode_AddsHttpEndpoint() Assert.Contains(endpoints, e => e.Name == "http"); } + [Fact] + public void PublishAsHostedAgent_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) + .PublishAsHostedAgent(project); + + builder.Build(); + + Assert.True(app.Resource.TryGetEndpoints(out var endpoints)); + var httpEndpoints = endpoints.Where(e => e.Name == "http").ToList(); + Assert.Single(httpEndpoints); + Assert.Equal(5000, httpEndpoints[0].TargetPort); + Assert.True(httpEndpoints[0].IsProxied); + } + + [Fact] + public void PublishAsHostedAgent_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") + .PublishAsHostedAgent(project); + + builder.Build(); + + Assert.True(app.Resource.TryGetEndpoints(out var endpoints)); + var httpEndpoint = endpoints.Single(e => e.Name == "http"); + Assert.Null(httpEndpoint.Port); + } + [Fact] public void PublishAsHostedAgent_InRunMode_ConfiguresHealthCheck() {