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 @@ -161,12 +161,66 @@ private async Task<ProjectsAgentVersion> 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<AgentEndpointProtocol> GetAgentEndpointProtocols(IEnumerable<ProtocolVersionRecord> protocolVersions)
{
var endpointProtocols = new List<AgentEndpointProtocol>();

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,
Expand Down Expand Up @@ -253,6 +307,15 @@ internal static async Task<Dictionary<string, string>> GetResolvedEnvironmentVar
var resolvedEnvVars = new Dictionary<string, string>();
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:
Expand Down
145 changes: 125 additions & 20 deletions src/Aspire.Hosting.Foundry/HostedAgent/HostedAgentBuilderExtension.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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";

/// <summary>
/// Configures the resource to run locally as a Microsoft Foundry hosted agent.
Expand Down Expand Up @@ -46,16 +47,16 @@ public static IResourceBuilder<T> AsHostedAgent<T>(this IResourceBuilder<T> 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<HostedAgentConfiguration> overload above, which exposes
// the full HostedAgentConfiguration surface (tools, content filters, container protocol versions, etc.).
// .NET callers should keep using the Action<HostedAgentConfiguration> overload, which exposes the
// full HostedAgentConfiguration surface (tools, content filters, container protocol versions, etc.).

/// <summary>
/// Configures the resource to run and publish as a hosted agent in Microsoft Foundry, targeting the specified Foundry project.
/// </summary>
/// <typeparam name="T">The type of resource being configured.</typeparam>
/// <param name="builder">The resource builder for the compute resource.</param>
/// <param name="project">The Microsoft Foundry project the hosted agent is deployed into.</param>
/// <param name="options">Optional hosted agent deployment options (description, CPU, memory, metadata, environment variables) applied in publish mode.</param>
/// <param name="options">Optional hosted agent deployment options. Protocols apply in run and publish mode; other options apply in publish mode.</param>
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/> for chaining.</returns>
/// <ats-returns>The resource builder.</ats-returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="builder"/> or <paramref name="project"/> is <see langword="null"/>.</exception>
Expand All @@ -69,7 +70,7 @@ internal static IResourceBuilder<T> AsHostedAgentForExport<T>(
ArgumentNullException.ThrowIfNull(project);

Action<HostedAgentConfiguration>? configure = options is null ? null : options.ApplyTo;
return AsHostedAgent(builder, project: project, configure: configure);
return ConfigureAsHostedAgent(builder, project: project, configure: configure);
}

/// <summary>
Expand All @@ -80,20 +81,33 @@ internal static IResourceBuilder<T> AsHostedAgentForExport<T>(
/// <typeparam name="T">The type of resource being configured.</typeparam>
/// <param name="builder">The resource builder for the compute resource.</param>
/// <param name="project">Optional Microsoft Foundry project resource used for both run and publish mode configuration. When <see langword="null"/>, an existing Foundry project in the model is reused or a new project is created in publish mode.</param>
/// <param name="configure">A callback to configure hosted agent deployment options in publish mode.</param>
/// <param name="configure">A callback to configure hosted agent deployment options.</param>
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/> for chaining.</returns>
[AspireExportIgnore(Reason = "Action callback shape is awkward for polyglot hosts; the HostedAgentOptions overload is exported instead.")]
/// <remarks>
/// The <see cref="HostedAgentConfiguration.ContainerProtocolVersions"/> setting affects both run and publish mode.
/// Other settings are used in publish mode.
/// </remarks>
[AspireExportIgnore(Reason = "Action callback shape is awkward for polyglot hosts; the HostedAgentOptions DTO shape is exported instead.")]
public static IResourceBuilder<T> AsHostedAgent<T>(
this IResourceBuilder<T> builder,
IResourceBuilder<AzureCognitiveServicesProjectResource>? project,
Action<HostedAgentConfiguration>? configure = null)
where T : IResourceWithEndpoints, IResourceWithEnvironment, IComputeResource
{
return ConfigureAsHostedAgent(builder, project, configure);
}

private static IResourceBuilder<T> ConfigureAsHostedAgent<T>(
this IResourceBuilder<T> builder,
IResourceBuilder<AzureCognitiveServicesProjectResource>? project,
Action<HostedAgentConfiguration>? configure)
where T : IResourceWithEndpoints, IResourceWithEnvironment, IComputeResource
{
ArgumentNullException.ThrowIfNull(builder);

if (builder.ApplicationBuilder.ExecutionContext.IsRunMode)
{
ConfigureRunMode(builder);
ConfigureRunMode(builder, configure);

if (project is not null)
{
Expand All @@ -116,7 +130,7 @@ public static IResourceBuilder<T> AsHostedAgent<T>(
/// </summary>
/// <typeparam name="T">The type of resource being configured.</typeparam>
/// <param name="builder">The resource builder for the compute resource.</param>
/// <param name="configure">A callback to configure hosted agent deployment options in publish mode.</param>
/// <param name="configure">A callback to configure hosted agent deployment options.</param>
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/> for chaining.</returns>
[AspireExportIgnore(Reason = "Subset of the full AsHostedAgent overload.")]
public static IResourceBuilder<T> AsHostedAgent<T>(
Expand Down Expand Up @@ -157,9 +171,11 @@ private static IResourceBuilder<AzureCognitiveServicesProjectResource> ResolvePr
.AddProject($"{builder.Resource.Name}-proj");
}

private static void ConfigureRunMode<T>(IResourceBuilder<T> builder)
private static void ConfigureRunMode<T>(IResourceBuilder<T> builder, Action<HostedAgentConfiguration>? 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<EndpointAnnotation>().FirstOrDefault(e => e.Name == "http");
Expand All @@ -175,14 +191,14 @@ private static void ConfigureRunMode<T>(IResourceBuilder<T> 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()
Expand All @@ -195,7 +211,7 @@ private static void ConfigureRunMode<T>(IResourceBuilder<T> builder)
{
var interactionService = ctx.ServiceProvider.GetRequiredService<IInteractionService>();
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.",
Expand All @@ -208,7 +224,7 @@ private static void ConfigureRunMode<T>(IResourceBuilder<T> 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 =>
{
Expand All @@ -224,17 +240,32 @@ private static void ConfigureRunMode<T>(IResourceBuilder<T> builder)
CommandResultFormat.Text);
}

var responseJson = await response.Content.ReadFromJsonAsync<JsonObject>(cancellationToken: ctx.CancellationToken).ConfigureAwait(true);
if (responseJson is null)
if (protocol.ExpectsJsonResponse)
{
var responseJson = await response.Content.ReadFromJsonAsync<JsonElement>(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);
},
}
Expand All @@ -261,6 +292,47 @@ private static void ConfigureRunMode<T>(IResourceBuilder<T> builder)
});
}

private static HostedAgentRunProtocol GetRunProtocol(Action<HostedAgentConfiguration>? 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<HostedAgentConfiguration>? 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();
}
Comment thread
davidfowl marked this conversation as resolved.

private static void ConfigurePublishMode<T>(
IResourceBuilder<T> builder,
IResourceBuilder<AzureCognitiveServicesProjectResource> project,
Expand Down Expand Up @@ -348,4 +420,37 @@ private static void ConfigurePublishMode<T>(
.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<string, HttpContent> CreateRequestContent { get; init; }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -165,6 +166,30 @@ private static void ValidateEnvironmentVariableNames(IEnumerable<string> environ
$"Invalid name(s): '{string.Join("', '", invalidNames)}'");
}

private static void ValidateEnvironmentVariableNamesAreNotReserved(IEnumerable<string> 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();
Expand Down
Loading
Loading