diff --git a/src/Aspire.Hosting.Foundry/HostedAgent/AzureHostedAgentResource.cs b/src/Aspire.Hosting.Foundry/HostedAgent/AzureHostedAgentResource.cs index 0fbcfa32a56..1c4f139d339 100644 --- a/src/Aspire.Hosting.Foundry/HostedAgent/AzureHostedAgentResource.cs +++ b/src/Aspire.Hosting.Foundry/HostedAgent/AzureHostedAgentResource.cs @@ -161,12 +161,66 @@ private async Task DeployAsync(PipelineStepContext context cancellationToken: context.CancellationToken ).ConfigureAwait(false); + await UpdateAgentEndpointProtocolsAsync(projectClient.AgentAdministrationClient, def, context.CancellationToken).ConfigureAwait(false); + // Foundry should do this automatically in the future. await AssignFoundryRoleToAgentIdentityAsync(context, project, result.Value, provisioningContext).ConfigureAwait(false); return result.Value; } + private async Task UpdateAgentEndpointProtocolsAsync(AgentAdministrationClient agentsClient, HostedAgentConfiguration configuration, CancellationToken cancellationToken) + { + var endpointProtocols = GetAgentEndpointProtocols(configuration.ContainerProtocolVersions); + if (endpointProtocols.Count == 0) + { + return; + } + + var endpoint = new AgentEndpoint(); + foreach (var protocol in endpointProtocols) + { + endpoint.Protocols.Add(protocol); + } + + // Creating a hosted-agent version does not update the endpoint's advertised protocols; + // keep routing in sync so endpoint-scoped invocations can reach the selected version. + await agentsClient.PatchAgentObjectAsync( + Name, + new PatchAgentOptions + { + AgentEndpoint = endpoint + }, + cancellationToken).ConfigureAwait(false); + } + + internal static IReadOnlyList GetAgentEndpointProtocols(IEnumerable protocolVersions) + { + var endpointProtocols = new List(); + + foreach (var protocolVersion in protocolVersions) + { + var endpointProtocol = ToAgentEndpointProtocol(protocolVersion.Protocol); + if (!endpointProtocols.Contains(endpointProtocol)) + { + endpointProtocols.Add(endpointProtocol); + } + } + + return endpointProtocols; + } + + private static AgentEndpointProtocol ToAgentEndpointProtocol(ProjectsAgentProtocol protocol) + { + return protocol.ToString() switch + { + "activity_protocol" => AgentEndpointProtocol.Activity, + "invocations" => AgentEndpointProtocol.Invocations, + "responses" => AgentEndpointProtocol.Responses, + var value => new AgentEndpointProtocol(value) + }; + } + private async Task AssignFoundryRoleToAgentIdentityAsync( PipelineStepContext context, AzureCognitiveServicesProjectResource project, @@ -253,6 +307,15 @@ internal static async Task> GetResolvedEnvironmentVar var resolvedEnvVars = new Dictionary(); foreach (var (key, value) in collectedEnvVars) { + if (HostedAgentConfiguration.IsReservedEnvironmentVariableName(key)) + { + // Foundry injects platform-owned variables such as PORT itself. Some Aspire resource + // types use these variables to model local/container startup, but forwarding them in + // the hosted-agent definition causes Foundry to reject the version payload. + logger.LogDebug("Environment variable '{Key}' for resource '{Name}' is reserved by Foundry Hosted Agents and will be skipped.", key, resource.Name); + continue; + } + switch (value) { case null: diff --git a/src/Aspire.Hosting.Foundry/HostedAgent/HostedAgentBuilderExtension.cs b/src/Aspire.Hosting.Foundry/HostedAgent/HostedAgentBuilderExtension.cs index d82ad750c79..60104ccc4d3 100644 --- a/src/Aspire.Hosting.Foundry/HostedAgent/HostedAgentBuilderExtension.cs +++ b/src/Aspire.Hosting.Foundry/HostedAgent/HostedAgentBuilderExtension.cs @@ -3,7 +3,6 @@ using System.Net.Http.Json; using System.Text.Json; -using System.Text.Json.Nodes; using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Foundry; using Microsoft.Extensions.DependencyInjection; @@ -16,6 +15,8 @@ namespace Aspire.Hosting; public static class HostedAgentResourceBuilderExtensions { private static readonly JsonSerializerOptions s_indentedJsonOptions = new() { WriteIndented = true }; + private const string ResponsesProtocol = "responses"; + private const string InvocationsProtocol = "invocations"; /// /// Configures the resource to run locally as a Microsoft Foundry hosted agent. @@ -46,8 +47,8 @@ public static IResourceBuilder AsHostedAgent(this IResourceBuilder buil // The internal AsHostedAgentForExport overload below is the polyglot-exported version of AsHostedAgent. // The method name differs from AsHostedAgent to avoid C# overload ambiguity with the Action-based // overload; the polyglot-facing name is set back to "asHostedAgent" via [AspireExport(MethodName)]. - // .NET callers should keep using the Action overload above, which exposes - // the full HostedAgentConfiguration surface (tools, content filters, container protocol versions, etc.). + // .NET callers should keep using the Action overload, which exposes the + // full HostedAgentConfiguration surface (tools, content filters, container protocol versions, etc.). /// /// Configures the resource to run and publish as a hosted agent in Microsoft Foundry, targeting the specified Foundry project. @@ -55,7 +56,7 @@ public static IResourceBuilder AsHostedAgent(this IResourceBuilder buil /// The type of resource being configured. /// The resource builder for the compute resource. /// The Microsoft Foundry project the hosted agent is deployed into. - /// Optional hosted agent deployment options (description, CPU, memory, metadata, environment variables) applied in publish mode. + /// Optional hosted agent deployment options. Protocols apply in run and publish mode; other options apply in publish mode. /// A reference to the for chaining. /// The resource builder. /// Thrown when or is . @@ -69,7 +70,7 @@ internal static IResourceBuilder AsHostedAgentForExport( ArgumentNullException.ThrowIfNull(project); Action? configure = options is null ? null : options.ApplyTo; - return AsHostedAgent(builder, project: project, configure: configure); + return ConfigureAsHostedAgent(builder, project: project, configure: configure); } /// @@ -80,20 +81,33 @@ internal static IResourceBuilder AsHostedAgentForExport( /// The type of resource being configured. /// The resource builder for the compute resource. /// Optional Microsoft Foundry project resource used for both run and publish mode configuration. When , an existing Foundry project in the model is reused or a new project is created in publish mode. - /// A callback to configure hosted agent deployment options in publish mode. + /// A callback to configure hosted agent deployment options. /// A reference to the for chaining. - [AspireExportIgnore(Reason = "Action callback shape is awkward for polyglot hosts; the HostedAgentOptions overload is exported instead.")] + /// + /// The setting affects both run and publish mode. + /// Other settings are used in publish mode. + /// + [AspireExportIgnore(Reason = "Action callback shape is awkward for polyglot hosts; the HostedAgentOptions DTO shape is exported instead.")] public static IResourceBuilder AsHostedAgent( this IResourceBuilder builder, IResourceBuilder? project, Action? configure = null) where T : IResourceWithEndpoints, IResourceWithEnvironment, IComputeResource + { + return ConfigureAsHostedAgent(builder, project, configure); + } + + private static IResourceBuilder ConfigureAsHostedAgent( + this IResourceBuilder builder, + IResourceBuilder? project, + Action? configure) + where T : IResourceWithEndpoints, IResourceWithEnvironment, IComputeResource { ArgumentNullException.ThrowIfNull(builder); if (builder.ApplicationBuilder.ExecutionContext.IsRunMode) { - ConfigureRunMode(builder); + ConfigureRunMode(builder, configure); if (project is not null) { @@ -116,7 +130,7 @@ public static IResourceBuilder AsHostedAgent( /// /// The type of resource being configured. /// The resource builder for the compute resource. - /// A callback to configure hosted agent deployment options in publish mode. + /// A callback to configure hosted agent deployment options. /// A reference to the for chaining. [AspireExportIgnore(Reason = "Subset of the full AsHostedAgent overload.")] public static IResourceBuilder AsHostedAgent( @@ -157,9 +171,11 @@ private static IResourceBuilder ResolvePr .AddProject($"{builder.Resource.Name}-proj"); } - private static void ConfigureRunMode(IResourceBuilder builder) + private static void ConfigureRunMode(IResourceBuilder builder, Action? configure) where T : IResourceWithEndpoints, IResourceWithEnvironment, IComputeResource { + var protocol = GetRunProtocol(configure); + // Preserve any target port already configured on an existing "http" endpoint; // fall back to the default MAF agent port (8088) when none is set. var existingHttpEndpoint = builder.Resource.Annotations.OfType().FirstOrDefault(e => e.Name == "http"); @@ -175,14 +191,14 @@ private static void ConfigureRunMode(IResourceBuilder builder) { return; } - http.DisplayText = "Responses Endpoint"; + http.DisplayText = protocol.EndpointDisplayText; http.Url = new UriBuilder(http.Url) { - Path = "/responses" + Path = protocol.Path }.ToString(); }) .WithHttpCommand( - path: "/responses", + path: protocol.Path, displayName: "Send Message", endpointName: "http", commandOptions: new() @@ -195,7 +211,7 @@ private static void ConfigureRunMode(IResourceBuilder builder) { var interactionService = ctx.ServiceProvider.GetRequiredService(); var result = await interactionService.PromptInputAsync( - title: "Responses API", + title: protocol.PromptTitle, message: "Enter a message to send to the agent.", inputLabel: "Message", placeHolder: "I would like to know the weather today.", @@ -208,7 +224,7 @@ private static void ConfigureRunMode(IResourceBuilder builder) } var request = ctx.Request; var input = result.Data.Value; - request.Content = new StringContent(new JsonObject() { ["input"] = input }.ToString(), System.Text.Encoding.UTF8, "application/json"); + request.Content = protocol.CreateRequestContent(input); }, GetCommandResult = async ctx => { @@ -224,17 +240,32 @@ private static void ConfigureRunMode(IResourceBuilder builder) CommandResultFormat.Text); } - var responseJson = await response.Content.ReadFromJsonAsync(cancellationToken: ctx.CancellationToken).ConfigureAwait(true); - if (responseJson is null) + if (protocol.ExpectsJsonResponse) + { + var responseJson = await response.Content.ReadFromJsonAsync(cancellationToken: ctx.CancellationToken).ConfigureAwait(true); + if (responseJson.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined) + { + return CommandResults.Failure("Agent returned an empty response."); + } + + var formattedResponse = JsonSerializer.Serialize(responseJson, s_indentedJsonOptions); + return CommandResults.Success( + message: "Agent response received.", + result: formattedResponse, + resultFormat: CommandResultFormat.Json, + displayImmediately: true); + } + + var responseText = await response.Content.ReadAsStringAsync(ctx.CancellationToken).ConfigureAwait(true); + if (string.IsNullOrEmpty(responseText)) { return CommandResults.Failure("Agent returned an empty response."); } - var formattedResponse = JsonSerializer.Serialize(responseJson, s_indentedJsonOptions); return CommandResults.Success( message: "Agent response received.", - result: formattedResponse, - resultFormat: CommandResultFormat.Json, + result: responseText, + resultFormat: CommandResultFormat.Text, displayImmediately: true); }, } @@ -261,6 +292,47 @@ private static void ConfigureRunMode(IResourceBuilder builder) }); } + private static HostedAgentRunProtocol GetRunProtocol(Action? configure) + { + var protocol = GetConfiguredRunProtocol(configure); + if (string.IsNullOrWhiteSpace(protocol) || string.Equals(protocol, ResponsesProtocol, StringComparison.OrdinalIgnoreCase)) + { + return HostedAgentRunProtocol.Responses; + } + + if (string.Equals(protocol, InvocationsProtocol, StringComparison.OrdinalIgnoreCase)) + { + return HostedAgentRunProtocol.Invocations; + } + + throw new NotSupportedException($"Foundry hosted agent protocol '{protocol}' is not supported in run mode. Supported protocols: '{ResponsesProtocol}', '{InvocationsProtocol}'."); + } + + private static string? GetConfiguredRunProtocol(Action? configure) + { + if (configure is null) + { + return null; + } + + // Run mode does not need the deployment image, but the same configuration callback is also used in + // publish mode where the image is known. Use a scratch configuration here so protocol selection has + // one C# API surface across run and publish mode. + var configuration = new HostedAgentConfiguration(image: string.Empty); + try + { + configure(configuration); + } + catch (Exception ex) + { + throw new InvalidOperationException( + $"Failed to apply the hosted agent configuration callback while determining the Foundry hosted agent protocol for run mode. In run mode, only {nameof(HostedAgentConfiguration.ContainerProtocolVersions)} is used; other options can still be validated by the callback.", + ex); + } + + return configuration.ContainerProtocolVersions.FirstOrDefault()?.Protocol.ToString(); + } + private static void ConfigurePublishMode( IResourceBuilder builder, IResourceBuilder project, @@ -348,4 +420,37 @@ private static void ConfigurePublishMode( .WithIconName("Agents") .WithReferenceRelationship(target); } + + private sealed class HostedAgentRunProtocol + { + public static HostedAgentRunProtocol Responses { get; } = new() + { + Path = "/responses", + EndpointDisplayText = "Responses Endpoint", + PromptTitle = "Responses API", + ExpectsJsonResponse = true, + CreateRequestContent = input => JsonContent.Create(new { input }) + }; + + public static HostedAgentRunProtocol Invocations { get; } = new() + { + Path = "/invocations", + EndpointDisplayText = "Invocations Endpoint", + PromptTitle = "Invocations API", + ExpectsJsonResponse = false, + // Agent Framework's invocations host expects a JSON body with a "message" field: + // https://github.com/microsoft/agent-framework/blob/main/python/packages/foundry_hosting/agent_framework_foundry_hosting/_invocations.py + CreateRequestContent = input => JsonContent.Create(new { message = input }) + }; + + public required string Path { get; init; } + + public required string EndpointDisplayText { get; init; } + + public required string PromptTitle { get; init; } + + public required bool ExpectsJsonResponse { get; init; } + + public required Func CreateRequestContent { get; init; } + } } diff --git a/src/Aspire.Hosting.Foundry/HostedAgent/HostedAgentConfiguration.cs b/src/Aspire.Hosting.Foundry/HostedAgent/HostedAgentConfiguration.cs index 289dbc636b7..206e1f77326 100644 --- a/src/Aspire.Hosting.Foundry/HostedAgent/HostedAgentConfiguration.cs +++ b/src/Aspire.Hosting.Foundry/HostedAgent/HostedAgentConfiguration.cs @@ -116,6 +116,7 @@ public decimal Memory internal ProjectsAgentVersionCreationOptions ToProjectsAgentVersionCreationOptions(string targetResourceName) { ValidateEnvironmentVariableNames(EnvironmentVariables.Keys, targetResourceName); + ValidateEnvironmentVariableNamesAreNotReserved(EnvironmentVariables.Keys, targetResourceName); var def = new HostedAgentDefinition( ContainerProtocolVersions, @@ -165,6 +166,30 @@ private static void ValidateEnvironmentVariableNames(IEnumerable environ $"Invalid name(s): '{string.Join("', '", invalidNames)}'"); } + private static void ValidateEnvironmentVariableNamesAreNotReserved(IEnumerable environmentVariableNames, string? targetResourceName) + { + var reservedNames = environmentVariableNames + .Where(IsReservedEnvironmentVariableName) + .Order(StringComparer.Ordinal) + .ToArray(); + + if (reservedNames.Length == 0) + { + return; + } + + throw new DistributedApplicationException( + $"Foundry hosted agent for target resource '{targetResourceName}' contains environment variable names that are reserved by Foundry Hosted Agents. " + + $"Reserved name(s): '{string.Join("', '", reservedNames)}'"); + } + + internal static bool IsReservedEnvironmentVariableName(string name) + { + return string.Equals(name, "PORT", StringComparison.OrdinalIgnoreCase) || + name.StartsWith("FOUNDRY_", StringComparison.OrdinalIgnoreCase) || + name.StartsWith("AGENT_", StringComparison.OrdinalIgnoreCase); + } + // hosted agent environment variables must contain only letters, digits, or underscores. [GeneratedRegex("^[A-Za-z0-9_]+$")] private static partial Regex EnvironmentVariableNameRegex(); diff --git a/src/Aspire.Hosting.Foundry/HostedAgent/HostedAgentOptions.cs b/src/Aspire.Hosting.Foundry/HostedAgent/HostedAgentOptions.cs index 7cf11d3c78c..0cb543793db 100644 --- a/src/Aspire.Hosting.Foundry/HostedAgent/HostedAgentOptions.cs +++ b/src/Aspire.Hosting.Foundry/HostedAgent/HostedAgentOptions.cs @@ -1,11 +1,13 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using Azure.AI.Projects.Agents; + namespace Aspire.Hosting.Foundry; -// HostedAgentOptions exposes the subset of HostedAgentConfiguration that is meaningful to non-.NET -// app hosts. .NET callers should use the AsHostedAgent overload that takes Action -// to access the full configuration surface (tools, content filters, container protocol versions, etc.). +// HostedAgentOptions exposes the subset of HostedAgentConfiguration that can be shared by .NET and +// polyglot app hosts. .NET callers can use the AsHostedAgent overload that takes +// Action when they need the full Azure SDK-specific configuration surface. /// /// Options that control how a compute resource is deployed as a Microsoft Foundry hosted agent. @@ -45,6 +47,16 @@ internal sealed class HostedAgentOptions /// public IDictionary EnvironmentVariables { get; init; } = new Dictionary(); + /// + /// Protocol versions that the hosted agent container supports for ingress communication. + /// When not set, the hosted agent default responses protocol is used. + /// + /// + /// In run mode, the first protocol entry selects the dashboard URL and HTTP command protocol. + /// In publish mode, all entries are emitted to the Foundry hosted agent definition. + /// + public IList Protocols { get; init; } = []; + internal void ApplyTo(HostedAgentConfiguration configuration) { if (Description is not null) @@ -73,5 +85,77 @@ internal void ApplyTo(HostedAgentConfiguration configuration) { configuration.EnvironmentVariables[kvp.Key] = kvp.Value; } + + var protocols = ValidateProtocols(); + if (protocols.Count > 0) + { + var protocolVersionRecords = protocols.Select(ToProtocolVersionRecord).ToArray(); + + configuration.ContainerProtocolVersions.Clear(); + foreach (var record in protocolVersionRecords) + { + configuration.ContainerProtocolVersions.Add(record); + } + } } + + private IList ValidateProtocols() + { + if (Protocols is null) + { + throw new ArgumentNullException(nameof(Protocols), "Hosted agent protocols cannot be null."); + } + + foreach (var protocol in Protocols) + { + ValidateProtocol(protocol); + } + + return Protocols; + } + + private static void ValidateProtocol(HostedAgentProtocolVersion protocolVersion) + { + if (protocolVersion is null) + { + throw new ArgumentNullException(nameof(protocolVersion), "Hosted agent protocols cannot contain null entries."); + } + + if (string.IsNullOrWhiteSpace(protocolVersion.Protocol)) + { + ThrowInvalidProtocolProperty(nameof(HostedAgentProtocolVersion.Protocol), "Hosted agent protocol cannot be null, empty, or whitespace."); + } + + if (string.IsNullOrWhiteSpace(protocolVersion.Version)) + { + ThrowInvalidProtocolProperty(nameof(HostedAgentProtocolVersion.Version), "Hosted agent protocol version cannot be null, empty, or whitespace."); + } + } + + private static void ThrowInvalidProtocolProperty(string propertyName, string message) + { + throw new ArgumentException(message, propertyName); + } + + private static ProtocolVersionRecord ToProtocolVersionRecord(HostedAgentProtocolVersion protocolVersion) + { + return new ProtocolVersionRecord(new ProjectsAgentProtocol(protocolVersion.Protocol), protocolVersion.Version); + } +} + +/// +/// A protocol and version supported by a Microsoft Foundry hosted agent container. +/// +[AspireDto] +internal sealed class HostedAgentProtocolVersion +{ + /// + /// The protocol name, such as responses or invocations. + /// + public required string Protocol { get; init; } + + /// + /// The protocol version, such as 1.0.0. + /// + public required string Version { get; init; } } diff --git a/tests/Aspire.Hosting.Foundry.Tests/HostedAgentConfigurationTests.cs b/tests/Aspire.Hosting.Foundry.Tests/HostedAgentConfigurationTests.cs index 27406e4a25f..2b027da493d 100644 --- a/tests/Aspire.Hosting.Foundry.Tests/HostedAgentConfigurationTests.cs +++ b/tests/Aspire.Hosting.Foundry.Tests/HostedAgentConfigurationTests.cs @@ -98,6 +98,21 @@ public void ToProjectsAgentVersionCreationOptions_ThrowsForInvalidEnvironmentVar ex.Message); } + [Fact] + public void ToProjectsAgentVersionCreationOptions_ThrowsForReservedEnvironmentVariableNames() + { + var config = new HostedAgentConfiguration("myimage:latest"); + config.EnvironmentVariables["PORT"] = "8000"; + config.EnvironmentVariables["AGENT_NAME"] = "agent"; + config.EnvironmentVariables["FOUNDRY_MODE"] = "hosted"; + + var ex = Assert.Throws(() => config.ToProjectsAgentVersionCreationOptions("target")); + + Assert.Equal( + "Foundry hosted agent for target resource 'target' contains environment variable names that are reserved by Foundry Hosted Agents. Reserved name(s): 'AGENT_NAME', 'FOUNDRY_MODE', 'PORT'", + ex.Message); + } + [Fact] public void DefaultMetadata_ContainsDeployedByAndOn() { diff --git a/tests/Aspire.Hosting.Foundry.Tests/HostedAgentExtensionTests.cs b/tests/Aspire.Hosting.Foundry.Tests/HostedAgentExtensionTests.cs index cb1ac78563d..8405cd2e25a 100644 --- a/tests/Aspire.Hosting.Foundry.Tests/HostedAgentExtensionTests.cs +++ b/tests/Aspire.Hosting.Foundry.Tests/HostedAgentExtensionTests.cs @@ -7,7 +7,9 @@ using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Tests.Utils; using Aspire.Hosting.Utils; +using Azure.AI.Projects.Agents; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; namespace Aspire.Hosting.Foundry.Tests; @@ -74,11 +76,62 @@ public void AsHostedAgent_InRunMode_ConfiguresSendMessageCommand() var resource = builder.Resources.Single(r => r.Name == "agent"); var command = Assert.Single(resource.Annotations.OfType()); Assert.Equal("Send Message", command.DisplayName); + Assert.EndsWith("-/responses", command.Name); Assert.Equal("ChatSparkle", command.IconName); Assert.Equal(IconVariant.Regular, command.IconVariant); Assert.True(command.IsHighlighted); } + [Fact] + public async Task AsHostedAgent_InRunMode_WithInvocationsProtocol_ConfiguresEndpointAndCommand() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); + var project = builder.AddFoundry("account") + .AddProject("my-project"); + builder.AddPythonApp("agent", "./app.py", "main:app") + .AsHostedAgent(project, configuration => + { + configuration.ContainerProtocolVersions.Clear(); + configuration.ContainerProtocolVersions.Add(new ProtocolVersionRecord(ProjectsAgentProtocol.Invocations, "1.0.0")); + }); + + using var app = builder.Build(); + + var resource = builder.Resources.Single(r => r.Name == "agent"); + var command = Assert.Single(resource.Annotations.OfType()); + Assert.EndsWith("-/invocations", command.Name); + + var urlsCallback = Assert.Single(resource.Annotations.OfType()); + var url = new ResourceUrlAnnotation + { + Url = "http://localhost:1234", + Endpoint = ((IResourceWithEndpoints)resource).GetEndpoint("http") + }; + var urls = new List { url }; + var context = new ResourceUrlsCallbackContext( + app.Services.GetRequiredService(), + resource, + urls); + + await urlsCallback.Callback(context); + + Assert.Equal("Invocations Endpoint", url.DisplayText); + Assert.Equal("http://localhost:1234/invocations", url.Url); + } + + [Fact] + public void AsHostedAgent_InRunMode_WrapsConfigurationCallbackFailures() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); + + var ex = Assert.Throws(() => + builder.AddPythonApp("agent", "./app.py", "main:app") + .AsHostedAgent(configuration => configuration.Cpu = 4.0m)); + + Assert.Contains("run mode", ex.Message); + Assert.IsType(ex.InnerException); + } + [Fact] public void AsHostedAgent_InPublishMode_DoesNotValidateRegion() { @@ -189,7 +242,15 @@ public void AsHostedAgent_WithOptions_AppliesAllPropertiesToConfiguration() Cpu = 1m, Memory = 2m, Metadata = { ["scenario"] = "unit-test" }, - EnvironmentVariables = { ["MY_VAR"] = "my-value" } + EnvironmentVariables = { ["MY_VAR"] = "my-value" }, + Protocols = + { + new HostedAgentProtocolVersion + { + Protocol = "invocations", + Version = "1.0.0" + } + } }; builder.AddPythonApp("agent", "./app.py", "main:app") @@ -207,6 +268,75 @@ public void AsHostedAgent_WithOptions_AppliesAllPropertiesToConfiguration() Assert.Equal(2m, configuration.Memory); Assert.Equal("unit-test", configuration.Metadata["scenario"]); Assert.Equal("my-value", configuration.EnvironmentVariables["MY_VAR"]); + var protocol = Assert.Single(configuration.ContainerProtocolVersions); + Assert.Equal(ProjectsAgentProtocol.Invocations, protocol.Protocol); + Assert.Equal("1.0.0", protocol.Version); + } + + [Fact] + public async Task GetResolvedEnvironmentVariables_DoesNotForwardFoundryReservedTargetEnvironmentVariables() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var agent = builder.AddExecutable("agent", "python", ".") + .WithEnvironment("PORT", "8000") + .WithEnvironment("AGENT_NAME", "agent") + .WithEnvironment("FOUNDRY_MODE", "hosted") + .WithEnvironment("MY_VAR", "my-value"); + + using var app = builder.Build(); + var hostedAgent = new AzureHostedAgentResource("agent-ha", agent.Resource); + + var envVars = await AzureHostedAgentResource.GetResolvedEnvironmentVariablesAsync( + app.Services.GetRequiredService(), + hostedAgent, + agent.Resource, + NullLogger.Instance, + CancellationToken.None); + + Assert.DoesNotContain("PORT", envVars.Keys); + Assert.DoesNotContain("AGENT_NAME", envVars.Keys); + Assert.DoesNotContain("FOUNDRY_MODE", envVars.Keys); + Assert.Equal("my-value", envVars["MY_VAR"]); + } + + [Theory] + [InlineData("", "1.0.0", nameof(HostedAgentProtocolVersion.Protocol))] + [InlineData("invocations", "", nameof(HostedAgentProtocolVersion.Version))] + public void AsHostedAgent_WithInvalidProtocolOptions_ThrowsWithPropertyName(string protocol, string version, string expectedParamName) + { + var options = new HostedAgentOptions + { + Protocols = + { + new HostedAgentProtocolVersion + { + Protocol = protocol, + Version = version + } + } + }; + + var ex = Assert.Throws(() => options.ApplyTo(new HostedAgentConfiguration("test-image"))); + Assert.Equal(expectedParamName, ex.ParamName); + } + + [Fact] + public void GetAgentEndpointProtocols_MapsContainerProtocolsToEndpointProtocols() + { + var endpointProtocols = AzureHostedAgentResource.GetAgentEndpointProtocols( + [ + new ProtocolVersionRecord(ProjectsAgentProtocol.Invocations, "1.0.0"), + new ProtocolVersionRecord(ProjectsAgentProtocol.Responses, "1.0.0"), + new ProtocolVersionRecord(ProjectsAgentProtocol.ActivityProtocol, "1.0.0"), + new ProtocolVersionRecord(ProjectsAgentProtocol.Invocations, "1.1.0") + ]); + + Assert.Collection( + endpointProtocols, + protocol => Assert.Equal(AgentEndpointProtocol.Invocations, protocol), + protocol => Assert.Equal(AgentEndpointProtocol.Responses, protocol), + protocol => Assert.Equal(AgentEndpointProtocol.Activity, protocol)); } [Fact] diff --git a/tests/PolyglotAppHosts/Aspire.Hosting.Foundry/TypeScript/apphost.mts b/tests/PolyglotAppHosts/Aspire.Hosting.Foundry/TypeScript/apphost.mts index 2479a1aeba8..936eb550345 100644 --- a/tests/PolyglotAppHosts/Aspire.Hosting.Foundry/TypeScript/apphost.mts +++ b/tests/PolyglotAppHosts/Aspire.Hosting.Foundry/TypeScript/apphost.mts @@ -103,6 +103,11 @@ const server = http.createServer((req, res) => { res.end(JSON.stringify({ output: 'hello from validation app host' })); return; } + if (req.url === '/invocations') { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ response: 'hello from validation app host' })); + return; + } res.writeHead(404); res.end(); }); @@ -115,7 +120,8 @@ await hostedAgent.asHostedAgent(project, { cpu: 1, memory: 2, metadata: { scenario: 'validation' }, - environmentVariables: { VALIDATION_MODE: 'true' } + environmentVariables: { VALIDATION_MODE: 'true' }, + protocols: [{ protocol: 'invocations', version: '1.0.0' }] }); const api = await builder.addContainer('api', 'nginx');