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()
{