Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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 @@ -65,13 +65,14 @@ public static class FoundryHostingExtensions
public static IServiceCollection AddFoundryResponses(this IServiceCollection services, Action<FoundryResponsesOptions>? configure = null)
{
_ = Throw.IfNull(services);
bool agentHostControlsListenPort = IsAgentHostBuilder(services);
FoundryResponsesOptions configuredOptions = CreateFoundryResponsesOptions(configure);
bool serverAdded = AddResponsesServerOnce(
services,
configuredOptions,
configure is not null);
services.AddHealthChecks();
ConfigureFoundryListenPort(services);
ConfigureFoundryListenPort(services, agentHostControlsListenPort);
ConfigureFoundryResponsesOptions(
services,
configuredOptions,
Expand Down Expand Up @@ -121,13 +122,14 @@ public static IServiceCollection AddFoundryResponses(
_ = Throw.IfNull(services);
_ = Throw.IfNull(agent);

bool agentHostControlsListenPort = IsAgentHostBuilder(services);
FoundryResponsesOptions configuredOptions = CreateFoundryResponsesOptions(configure);
bool serverAdded = AddResponsesServerOnce(
services,
configuredOptions,
configure is not null);
services.AddHealthChecks();
ConfigureFoundryListenPort(services);
ConfigureFoundryListenPort(services, agentHostControlsListenPort);
ConfigureFoundryResponsesOptions(
services,
configuredOptions,
Expand Down Expand Up @@ -520,9 +522,10 @@ private sealed class FoundryResponsesServerMarker(
/// is resolved.
/// </para>
/// </remarks>
private static void ConfigureFoundryListenPort(IServiceCollection services)
private static void ConfigureFoundryListenPort(IServiceCollection services, bool agentHostControlsListenPort)
{
if (services.Any(static d => d.ServiceType == typeof(FoundryListenPortMarker)))
if (agentHostControlsListenPort ||
services.Any(static d => d.ServiceType == typeof(FoundryListenPortMarker)))
{
return;
}
Expand All @@ -540,6 +543,20 @@ private static void ConfigureFoundryListenPort(IServiceCollection services)
});
}

/// <summary>
/// Detects the AgentServer builder, which configures its own Kestrel listener during
/// <c>AgentHostBuilder.Build()</c>.
/// </summary>
/// <remarks>
/// <c>AgentHostBuilder</c> registers its public <see cref="ServerVersionRegistry"/> instance
/// before callers add protocol services. A standalone <see cref="WebApplicationBuilder"/> does
/// not have that instance registration and still needs this package to configure the Foundry port.
/// </remarks>
private static bool IsAgentHostBuilder(IServiceCollection services) =>
services.Any(static descriptor =>
descriptor.ServiceType == typeof(ServerVersionRegistry) &&
Comment thread
rogerbarreto marked this conversation as resolved.
Outdated
descriptor.ImplementationInstance is ServerVersionRegistry);

/// <summary>
/// Reads the listen port from configuration, applying the same contract as
/// <see cref="FoundryEnvironment.Port"/>: <see cref="DefaultListenPort"/> when unset, otherwise
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,17 @@
_ => throw new InvalidOperationException($"Unknown IT_SCENARIO '{scenario}'.")
};

if (scenario == "happy-path")
{
var agentHostBuilder = AgentHost.CreateBuilder(args);
agentHostBuilder.Services.AddFoundryResponses(agent);
agentHostBuilder.RegisterProtocol("responses", endpoints => endpoints.MapFoundryResponses());

var agentHostApp = agentHostBuilder.Build();
agentHostApp.Run();
return;
}

var builder = WebApplication.CreateBuilder(args);

var port = Environment.GetEnvironmentVariable("PORT");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,17 @@
namespace Foundry.Hosting.IntegrationTests;

/// <summary>
/// Basic round trip, streaming, and container-instruction behaviour for a hosted Responses agent.
/// Store and session semantics live in <see cref="HostedResponsesStoreConfigTests"/>.
/// Basic round trip, streaming, and container-instruction behaviour for a hosted Responses agent
/// whose server is created by <c>AgentHost.CreateBuilder</c>. Store and session semantics live in
/// <see cref="HostedResponsesStoreConfigTests"/>.
/// </summary>
[Trait("Category", "FoundryHostedAgents")]
public sealed class HappyPathHostedAgentTests(HappyPathHostedAgentFixture fixture) : IClassFixture<HappyPathHostedAgentFixture>
{
private readonly HappyPathHostedAgentFixture _fixture = fixture;

[Fact]
public async Task RunAsync_ReturnsNonEmptyTextAsync()
public async Task AgentHostBuilder_StartsAndHandlesRequestAsync()
{
// Arrange
var agent = this._fixture.Agent;
Expand All @@ -26,6 +27,7 @@ public async Task RunAsync_ReturnsNonEmptyTextAsync()

// Assert
Assert.False(string.IsNullOrWhiteSpace(response.Text));
Assert.Contains("CONTAINER-OK", response.Text, StringComparison.OrdinalIgnoreCase);
}

[Fact]
Expand Down
5 changes: 5 additions & 0 deletions dotnet/tests/Foundry.Hosting.IntegrationTests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ the agent definition by each fixture, drives a `switch` in the test container's
`Program.cs` to wire up the scenario specific behavior (tools, toolbox, custom storage,
etc.).

The `happy-path` scenario creates the server with `AgentHost.CreateBuilder`. This covers
the recommended AgentServer builder, including its port and readiness configuration. The
remaining scenarios use `WebApplication.CreateBuilder`, so every live run covers both
supported hosting paths.

### Session sticky and user-identity scenario

`HostedSessionAndUserIdentityTests` (fixture `UserIdentityHostedAgentFixture`, agent
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using System.Collections.Generic;
using System.Net;
using System.Reflection;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Server.Kestrel.Core;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
Expand All @@ -15,9 +16,9 @@ namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;

/// <summary>
/// Verifies that <c>AddFoundryResponses</c> adds a Kestrel listener on the Foundry hosted-runtime
/// port for a plain <c>WebApplication.CreateBuilder</c> (Tier 3) host, so a source (ZIP) deployed
/// agent passes the platform readiness probe with no Dockerfile pinning the port, and that it
/// leaves the addresses of a host running outside Foundry alone.
/// port for a plain <c>WebApplication.CreateBuilder</c> (Tier 3) host, does not duplicate the
/// listener owned by <c>AgentHost.CreateBuilder</c> (Tier 2), and leaves the addresses of a host
/// running outside Foundry alone.
/// </summary>
/// <remarks>
/// Every case supplies its values through an in-memory <see cref="IConfiguration"/>, so no test
Expand Down Expand Up @@ -137,6 +138,46 @@ public void AddFoundryResponses_CalledTwiceWhenHosted_ListensOnFoundryPortOnce()
Assert.Equal([FoundryHostingExtensions.DefaultListenPort], GetCodeBackedPorts(services));
}

[Fact]
public void AddFoundryResponses_WithStandaloneAgentServerCore_ListensOnFoundryPortOnce()
{
// Arrange
var services = CreateServices();
services.AddAgentServerCore();

// Act
services.AddFoundryResponses();

// Assert
Assert.Equal([FoundryHostingExtensions.DefaultListenPort], GetCodeBackedPorts(services));
}

[Fact]
public async Task AddFoundryResponses_WithAgentHostBuilder_ListensOnFoundryPortOnceAsync()
{
// Arrange
var builder = AgentHost.CreateBuilder(
[
$"--{FoundryHostingExtensions.FoundryHostingEnvironmentKey}=foundry",
]);
var mockAgent = new Mock<AIAgent>();
mockAgent.SetupGet(a => a.Name).Returns("test-agent");

// Act
builder.Services.AddFoundryResponses(mockAgent.Object);
var app = builder.Build();

// Assert
try
{
Assert.Equal([FoundryHostingExtensions.DefaultListenPort], GetCodeBackedPorts(app.App.Services));
}
finally
{
await app.App.DisposeAsync();
}
}

/// <summary>
/// Builds a service collection whose <see cref="IConfiguration"/> carries the supplied values,
/// marking the process as Foundry-hosted unless <paramref name="hosted"/> says otherwise.
Expand All @@ -162,6 +203,11 @@ private static ServiceCollection CreateServices(bool hosted = true, Dictionary<s
private static List<int> GetCodeBackedPorts(IServiceCollection services)
{
using var provider = services.BuildServiceProvider();
return GetCodeBackedPorts(provider);
}

private static List<int> GetCodeBackedPorts(IServiceProvider provider)
{
var options = provider.GetRequiredService<IOptions<KestrelServerOptions>>().Value;

var property = typeof(KestrelServerOptions).GetProperty(
Expand Down
Loading