Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
8 changes: 4 additions & 4 deletions playground/FoundryAgents/FoundryAgents.AppHost/AppHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Projects.DotNetHostedAgent>("proj-dotnet-hosted-agent")
.WithHttpEndpoint(targetPort: 9000)
.WithReference(project).WithReference(chat).WaitFor(chat)
.WithComputeEnvironment(project);
.WithReference(chat).WaitFor(chat)
.AsHostedAgent(project);

// --- Prompt Agents ---

Expand Down
3 changes: 2 additions & 1 deletion src/Aspire.Hosting.Foundry/FoundryExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ public static IResourceBuilder<FoundryResource> AddFoundry(this IDistributedAppl

var resource = new FoundryResource(name, ConfigureInfrastructure);
return builder.AddResource(resource)
.WithIconName("AgentsAdd")
.WithDefaultRoleAssignments(CognitiveServicesBuiltInRole.GetBuiltInRoleName,
CognitiveServicesBuiltInRole.CognitiveServicesUser, CognitiveServicesBuiltInRole.CognitiveServicesOpenAIUser);
}
Expand Down Expand Up @@ -77,7 +78,7 @@ public static IResourceBuilder<FoundryDeploymentResource> AddDeployment(this IRe
deploymentBuilder.AsLocalDeployment(deployment);
}

return deploymentBuilder;
return deploymentBuilder.WithIconName("BoxMultiple");
}

/// <summary>
Expand Down
479 changes: 267 additions & 212 deletions src/Aspire.Hosting.Foundry/HostedAgent/HostedAgentBuilderExtension.cs

Large diffs are not rendered by default.

77 changes: 77 additions & 0 deletions src/Aspire.Hosting.Foundry/HostedAgent/HostedAgentOptions.cs
Original file line number Diff line number Diff line change
@@ -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<HostedAgentConfiguration>
// to access the full configuration surface (tools, content filters, container protocol versions, etc.).

/// <summary>
/// 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.
/// </summary>
[AspireDto]
internal sealed class HostedAgentOptions
{
/// <summary>
/// Human-readable description of the hosted agent surfaced in the Microsoft Foundry portal.
/// When not set, the hosted agent default description is used.
/// </summary>
public string? Description { get; set; }

/// <summary>
/// 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.
/// </summary>
public decimal? Cpu { get; set; }

/// <summary>
/// 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.
/// </summary>
public decimal? Memory { get; set; }

/// <summary>
/// Additional metadata key/value pairs to attach to the hosted agent definition.
/// Entries with the same key as an existing metadata entry overwrite it.
/// </summary>
public IDictionary<string, string> Metadata { get; init; } = new Dictionary<string, string>();

/// <summary>
/// Environment variables to set on the hosted agent container at runtime.
/// Entries with the same key as an existing environment variable overwrite it.
/// </summary>
public IDictionary<string, string> EnvironmentVariables { get; init; } = new Dictionary<string, string>();

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;
}
}
}
85 changes: 49 additions & 36 deletions src/Aspire.Hosting.Foundry/Project/ProjectBuilderExtension.cs
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,11 @@ public static IResourceBuilder<AzureCognitiveServicesProjectResource> AddProject
builder.ApplicationBuilder.Services.Configure<AzureProvisioningOptions>(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;
}

Expand Down Expand Up @@ -324,6 +328,12 @@ public static IResourceBuilder<FoundryDeploymentResource> AddModelDeployment(
return builder.ApplicationBuilder.CreateResourceBuilder(builder.Resource.Parent).AddDeployment(name, modelName, modelVersion, format);
}

private static bool RequiresContainerRegistryProvisioning(AzureCognitiveServicesProjectResource project)
{
return project.HasAnnotationOfType<RequiresHostedAgentRegistryAnnotation>()
|| project.HasAnnotationOfType<ContainerRegistryReferenceAnnotation>();
}

internal static void ConfigureInfrastructure(AzureResourceInfrastructure infra)
{
var prefix = infra.AspireResource.Name;
Expand Down Expand Up @@ -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<ContainerRegistryReferenceAnnotation>(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<ContainerRegistryReferenceAnnotation>(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<ProvisionableResource> capHostDeps = [];
Expand Down
12 changes: 10 additions & 2 deletions src/Aspire.Hosting.Foundry/Project/ProjectResource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,9 @@ public AzureCognitiveServicesProjectResource([ResourceName] string name, Action<
Description = $"Prepares Microsoft Foundry project {name} for deployment.",
Action = context =>
{
if (this.HasAnnotationOfType<ContainerRegistryReferenceAnnotation>() &&
DefaultContainerRegistry is not null)
if (DefaultContainerRegistry is not null &&
(this.HasAnnotationOfType<ContainerRegistryReferenceAnnotation>() ||
!this.HasAnnotationOfType<RequiresHostedAgentRegistryAnnotation>()))
{
context.Model.Resources.Remove(DefaultContainerRegistry);
DefaultContainerRegistry = null;
Expand Down Expand Up @@ -248,6 +249,13 @@ public bool TryGetAppIdentityResource([NotNullWhen(true)] out IAppIdentityResour
}
}

/// <summary>
/// Marks a Foundry project as needing container registry provisioning for hosted agent deployment.
/// </summary>
internal sealed class RequiresHostedAgentRegistryAnnotation : IResourceAnnotation
{
}

/// <summary>
/// Configuration for a Microsoft Foundry capability host.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ public static IResourceBuilder<AzurePromptAgentResource> AddPromptAgent(
var agent = new AzurePromptAgentResource(name, model.Resource.DeploymentName, project.Resource, instructions);

var agentBuilder = project.ApplicationBuilder.AddResource(agent)
.WithIconName("Agents")
.WithReferenceRelationship(project)
.WithReference(project);

Expand Down Expand Up @@ -103,7 +104,7 @@ public static IResourceBuilder<AzurePromptAgentResource> AddPromptAgent(
},
commandOptions: new()
{
IconName = "Agents",
IconName = "ChatSparkle",
IconVariant = IconVariant.Regular,
IsHighlighted = true,
Arguments =
Expand Down
2 changes: 1 addition & 1 deletion src/Aspire.Hosting.Foundry/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand why this line is new, it should be removed actually, or at least not added, the method doesn't exist anymore. Is that a merge conflict resolution error?

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
Expand Down
4 changes: 4 additions & 0 deletions src/Aspire.Hosting.Foundry/api/Aspire.Hosting.Foundry.cs
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,10 @@ public static ApplicationModel.IResourceBuilder<T> WithRoleAssignments<T>(this A

public static partial class HostedAgentResourceBuilderExtensions
{
[AspireExport]
public static ApplicationModel.IResourceBuilder<T> AsHostedAgent<T>(this ApplicationModel.IResourceBuilder<T> builder)
where T : ApplicationModel.IResourceWithEndpoints, ApplicationModel.IResourceWithEnvironment, ApplicationModel.IComputeResource { throw null; }

[AspireExport("withComputeEnvironmentExecutable", MethodName = "withComputeEnvironment")]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should have been renamed to prevent a conflict with the standard one

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It was changed, this is the api file ... I don't think this should have been merged @davidfowl ?

public static ApplicationModel.IResourceBuilder<T> WithComputeEnvironment<T>(this ApplicationModel.IResourceBuilder<T> builder, ApplicationModel.IResourceBuilder<Foundry.AzureCognitiveServicesProjectResource>? project = null, System.Action<Foundry.HostedAgentConfiguration>? configure = null)
where T : ApplicationModel.IResourceWithEndpoints, ApplicationModel.IResourceWithEnvironment, ApplicationModel.IComputeResource { throw null; }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,7 @@ static string GetRequiredConnectionValue(DbConnectionStringBuilder connectionBui

builder.AddProject<Projects.DotNetHostedAgent>("dotnet-hosted-agent")
.WithReference(chat).WaitFor(chat)
.WithComputeEnvironment(foundryProject);
.AsHostedAgent(foundryProject);

builder.Build().Run();
""");
Expand Down
2 changes: 1 addition & 1 deletion tests/Aspire.Hosting.Azure.Tests/AzureDeployerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1284,7 +1284,7 @@ public async Task DeployAsync_WithFoundryAndAzureContainerApps_CreatesCorrectDep
var acaEnv = builder.AddAzureContainerAppEnvironment("aca-env");

builder.AddProject<Project>("agent", launchProfileName: null)
.WithComputeEnvironment(foundryProject);
.AsHostedAgent(foundryProject);

builder.AddProject<Project>("api", launchProfileName: null)
.WithExternalHttpEndpoints()
Expand Down
20 changes: 9 additions & 11 deletions tests/Aspire.Hosting.Azure.Tests/FoundryExtensionsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<AzureContainerRegistryResource>());

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<AzureContainerRegistryResource>());
Assert.Null(project.Resource.ContainerRegistry);
}

[Fact]
Expand Down Expand Up @@ -295,7 +294,7 @@ public async Task WithComputeEnvironment_ResolvesExternalContainerAppReference()
var advisorAgent = builder.AddProject<Project>("advisoragent", launchProfileName: null)
.WithReference(weatherAgent)
.WaitFor(weatherAgent)
.WithComputeEnvironment(project);
.AsHostedAgent(project);

using var app = builder.Build();
await AzureManifestUtils.ExecuteBeforeStartHooksAsync(app, default);
Expand Down Expand Up @@ -326,7 +325,7 @@ public async Task WithComputeEnvironment_DoesNotSetReservedFoundryProjectEndpoin
.AddProject("my-project");

var advisorAgent = builder.AddProject<Project>("advisor-agent", launchProfileName: null)
.WithComputeEnvironment(project);
.AsHostedAgent(project);

using var app = builder.Build();
await AzureManifestUtils.ExecuteBeforeStartHooksAsync(app, default);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand All @@ -469,4 +468,3 @@ private sealed class Project : IProjectMetadata
}

}

Loading
Loading