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
4 changes: 2 additions & 2 deletions src/AgentCore.AspNetCore/DependencyInjection/AgentCoreBoot.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ internal sealed class AgentCoreBoot : IAsyncDisposable, IDisposable
/// <summary>Takes the options a host filled and the loggers the container holds.</summary>
/// <param name="options">The options every <c>Use*</c> seam wrote into.</param>
/// <param name="loggers">The container's factory, used unless the options name another.</param>
/// <param name="services">The container, read for the mapped routes the startup check walks.</param>
/// <param name="services">The container, read for host-registered seams the boot honors.</param>
public AgentCoreBoot(IOptions<AgentCoreOptions> options, ILoggerFactory loggers, IServiceProvider? services = null)
{
ArgumentNullException.ThrowIfNull(options);
Expand Down Expand Up @@ -152,7 +152,7 @@ internal T Release<T>(T resource)
/// </exception>
/// <exception cref="ConfigurationLoadException">
/// The document fails one of the eight checks, names a <c>kind</c> no registered adapter serves,
/// does not compile, or a mapped route names an entry it does not declare.
/// or does not compile.
/// </exception>
/// <exception cref="SecretResolutionException">One <c>${secret:name}</c> reference resolves to nothing.</exception>
internal async ValueTask BootAsync(CancellationToken cancellationToken)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
using AgentCore.AspNetCore.DependencyInjection.Startup;
using AgentCore.AspNetCore.Sessions;
using Microsoft.Agents.AI.Hosting;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.WebSockets;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
Expand Down Expand Up @@ -42,6 +43,7 @@ public static IServiceCollection AddAgentCore(

_ = services.AddSingleton<AgentCoreBoot>();
_ = services.AddHostedService<AgentCoreBootService>();
services.TryAddEnumerable(ServiceDescriptor.Transient<IStartupFilter, EntryRouteCheck>());
_ = services.AddSingleton(Boot(boot => boot.Configuration));
_ = services.AddSingleton(Boot(boot => boot.Secrets));
_ = services.AddSingleton(Boot(boot => boot.Bindings));
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
using AgentCore.Application.Configuration.Parsing;
using AgentCore.AspNetCore.Endpoints;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;

namespace AgentCore.AspNetCore.DependencyInjection.Startup
{
/// <summary>
/// Fails startup when an AgentCore route has no way to learn its entry, or names a selector the host never
/// registered.
/// </summary>
internal sealed class EntryRouteCheck : IStartupFilter
{
/// <inheritdoc/>
public Action<IApplicationBuilder> Configure(Action<IApplicationBuilder> next)
{
return app =>
{
next(app);
Check(app.ApplicationServices);
};
}

/// <summary>Walks every mapped route AgentCore tagged.</summary>
/// <param name="services">The root provider.</param>
/// <exception cref="ConfigurationLoadException">A route names no entry, or its selector is not registered.</exception>
internal static void Check(IServiceProvider services)
{
if (services.GetService<EndpointDataSource>() is not { } routes)
{
return;
}

IServiceProviderIsService registered = services.GetRequiredService<IServiceProviderIsService>();

foreach (RouteEndpoint route in routes.Endpoints.OfType<RouteEndpoint>())
{
if (route.Metadata.GetMetadata<AgentCoreRouteMetadata>() is null)
{
continue;
}

string pattern = route.RoutePattern.RawText ?? route.DisplayName ?? "(unnamed route)";

if (route.Metadata.GetMetadata<EntrySelectorMetadata>() is { } selector)
{
if (!registered.IsService(selector.SelectorType))
{
throw new ConfigurationLoadException(
$"The route '{pattern}' picks its entry with {selector.SelectorType.FullName}, which the "
+ "host never registered. Add it to the service collection.");
}
}
else if (route.RoutePattern.GetParameter(ResponsesEndpointRouteBuilderExtensions.EntryRouteParameter) is null)
{
throw new ConfigurationLoadException(
$"The route '{pattern}' carries no {{{ResponsesEndpointRouteBuilderExtensions.EntryRouteParameter}}} "
+ "parameter and no entry selector, so nothing names its entry. Add {entry} to the pattern, "
+ "or call SelectEntry on the route.");
}
}
}
}
}
67 changes: 67 additions & 0 deletions src/AgentCore.AspNetCore/Endpoints/AgentCoreEntries.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;

namespace AgentCore.AspNetCore.Endpoints
{
/// <summary>Learns the entry a request on an AgentCore route runs.</summary>
public static class AgentCoreEntries
{
/// <summary>
/// Runs the endpoint's selector, or reads <c>{entry}</c> when it has none, once per request, and keeps the
/// answer as <see cref="IEntryFeature"/>.
/// </summary>
/// <param name="http">A request that routing has already matched to an endpoint.</param>
/// <param name="cancellationToken">Cancels the selector.</param>
/// <returns>
/// The entry key, or <see langword="null"/> when the selector refused. A route with no selector and no
/// <c>{entry}</c> value answers the empty string, which no entry declares.
/// </returns>
/// <exception cref="InvalidOperationException">Routing has not matched the request to an endpoint yet.</exception>
public static async ValueTask<string?> ResolveAsync(HttpContext http, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(http);

if (http.Features.Get<IEntryFeature>() is { } known)
{
return known.Entry;
}

if (http.Features.Get<EntryRefused>() is not null)
{
return null;
}

Endpoint endpoint = http.GetEndpoint()
?? throw new InvalidOperationException(
"the request has no endpoint yet, so no route names its entry. Resolve the entry after "
+ "app.UseRouting().");

string? entry = endpoint.Metadata.GetMetadata<EntrySelectorMetadata>() is { } metadata
? await ((IEntrySelector)http.RequestServices.GetRequiredService(metadata.SelectorType))
.SelectAsync(http, cancellationToken)
.ConfigureAwait(false)
: http.Request.RouteValues[ResponsesEndpointRouteBuilderExtensions.EntryRouteParameter] as string ?? string.Empty;

if (entry is null)
{
http.Features.Set(EntryRefused.Instance);
}
else
{
http.Features.Set<IEntryFeature>(new EntryFeature(entry));
}

return entry;
}

private sealed class EntryFeature(string entry) : IEntryFeature
{
public string Entry { get; } = entry;
}

private sealed class EntryRefused
{
public static EntryRefused Instance { get; } = new();
}
}
}
15 changes: 15 additions & 0 deletions src/AgentCore.AspNetCore/Endpoints/AgentCoreRouteMetadata.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
namespace AgentCore.AspNetCore.Endpoints
{
/// <summary>
/// Tags a route AgentCore mapped, so the startup check finds every route that must learn its entry.
/// </summary>
internal sealed class AgentCoreRouteMetadata
{
/// <summary>Gets the one tag every AgentCore route shares.</summary>
public static AgentCoreRouteMetadata Instance { get; } = new();

private AgentCoreRouteMetadata()
{
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
using Microsoft.AspNetCore.Builder;

namespace AgentCore.AspNetCore.Endpoints
{
/// <summary>Attaches an <see cref="IEntrySelector"/> to a route AgentCore mapped.</summary>
public static class EntrySelectorEndpointConventionBuilderExtensions
{
/// <summary>Makes the route pick its entry with <typeparamref name="TSelector"/>, in place of the URL.</summary>
/// <typeparam name="TSelector">The selector. The host registers it; startup fails when it does not.</typeparam>
/// <param name="builder">The route, as a mapping call returned it.</param>
/// <returns>The same route, so a host chains its conventions.</returns>
public static IEndpointConventionBuilder SelectEntry<TSelector>(this IEndpointConventionBuilder builder)
where TSelector : class, IEntrySelector
{
ArgumentNullException.ThrowIfNull(builder);

return builder.WithMetadata(new EntrySelectorMetadata(typeof(TSelector)));
}
}
}
6 changes: 6 additions & 0 deletions src/AgentCore.AspNetCore/Endpoints/EntrySelectorMetadata.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace AgentCore.AspNetCore.Endpoints
{
/// <summary>Endpoint metadata that names the <see cref="IEntrySelector"/> a route picks its entry with.</summary>
/// <param name="SelectorType">The selector type, resolved from the request's services.</param>
internal sealed record EntrySelectorMetadata(Type SelectorType);
}
9 changes: 9 additions & 0 deletions src/AgentCore.AspNetCore/Endpoints/IEntryFeature.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
namespace AgentCore.AspNetCore.Endpoints
{
/// <summary>The entry this request runs, once it is known.</summary>
public interface IEntryFeature
{
/// <summary>Gets the entry key.</summary>
string Entry { get; }
}
}
17 changes: 17 additions & 0 deletions src/AgentCore.AspNetCore/Endpoints/IEntrySelector.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
using Microsoft.AspNetCore.Http;

namespace AgentCore.AspNetCore.Endpoints
{
/// <summary>
/// Picks the entry for one request on a route that carries this selector, so the server chooses the
/// agent and the URL does not.
/// </summary>
public interface IEntrySelector
{
/// <summary>Picks the entry this request runs.</summary>
/// <param name="http">The request, already authenticated when the host runs authentication first.</param>
/// <param name="cancellationToken">Cancels the pick.</param>
/// <returns>The entry key, or <see langword="null"/> to refuse the request with 403.</returns>
ValueTask<string?> SelectAsync(HttpContext http, CancellationToken cancellationToken);
}
}
31 changes: 17 additions & 14 deletions src/AgentCore.AspNetCore/Endpoints/ResponsesEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,33 +44,36 @@ public static IEndpointConventionBuilder MapResponses(this IEndpointRouteBuilder
return endpoints.MapResponses(DefaultPattern);
}

/// <summary>Maps every entry on one route, with the URL naming the entry.</summary>
/// <summary>Maps every entry on one route.</summary>
/// <param name="endpoints">The route builder of the host.</param>
/// <param name="pattern">The route to answer on. It must carry the <c>{entry}</c> parameter.</param>
/// <param name="pattern">
/// The route to answer on. It carries the <c>{entry}</c> parameter, or the host attaches an
/// <see cref="IEntrySelector"/> with <see cref="EntrySelectorEndpointConventionBuilderExtensions.SelectEntry{TSelector}"/>.
/// Startup fails when it has neither.
/// </param>
/// <returns>The mapped endpoint, so a host adds its own conventions.</returns>
/// <exception cref="ArgumentException"><paramref name="pattern"/> carries no <c>{entry}</c>.</exception>
public static IEndpointConventionBuilder MapResponses(this IEndpointRouteBuilder endpoints, string pattern)
{
ArgumentNullException.ThrowIfNull(endpoints);
ArgumentException.ThrowIfNullOrEmpty(pattern);

return !pattern.Contains("{" + EntryRouteParameter + "}", StringComparison.Ordinal)
? throw new ArgumentException(
$"The pattern '{pattern}' carries no {{{EntryRouteParameter}}} parameter, so no URL can name "
+ "an entry.",
nameof(pattern))
: endpoints.MapPost(pattern, HandleAsync);
return endpoints.MapPost(pattern, HandleAsync).WithMetadata(AgentCoreRouteMetadata.Instance);
}

/// <summary>Runs one turn of one conversation, and files the session under the ids the answer carries.</summary>
/// <remarks>
/// Every step answers its own refusal and returns <see langword="null"/>, so this reads as
/// the happy path alone.
/// </remarks>
private static async Task HandleAsync(HttpContext http)
{
CancellationToken cancellationToken = http.RequestAborted;
string entry = http.Request.RouteValues[EntryRouteParameter] as string ?? string.Empty;
if (await AgentCoreEntries.ResolveAsync(http, cancellationToken).ConfigureAwait(false) is not { } entry)
{
await ResponsesRequestReader.WriteErrorAsync(
http,
StatusCodes.Status403Forbidden,
"this route runs no entry for this caller.",
"entry_refused",
cancellationToken).ConfigureAwait(false);
return;
}

if (await ResponsesRequestReader.ReadBodyAsync(http, cancellationToken).ConfigureAwait(false) is not { } body)
{
Expand Down
8 changes: 8 additions & 0 deletions src/AgentCore.AspNetCore/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
Expand Up @@ -205,3 +205,11 @@ override sealed AgentCore.AspNetCore.Voice.ConversationInput.Barge.Equals(AgentC
override sealed AgentCore.AspNetCore.Voice.ConversationInput.Keypress.Equals(AgentCore.AspNetCore.Voice.ConversationInput? other) -> bool
override sealed AgentCore.AspNetCore.Voice.ConversationInput.Utterance.Equals(AgentCore.AspNetCore.Voice.ConversationInput? other) -> bool
override sealed AgentCore.AspNetCore.Voice.ConversationInput.Started.Equals(AgentCore.AspNetCore.Voice.ConversationInput? other) -> bool
AgentCore.AspNetCore.Endpoints.AgentCoreEntries
AgentCore.AspNetCore.Endpoints.EntrySelectorEndpointConventionBuilderExtensions
AgentCore.AspNetCore.Endpoints.IEntryFeature
AgentCore.AspNetCore.Endpoints.IEntryFeature.Entry.get -> string!
AgentCore.AspNetCore.Endpoints.IEntrySelector
AgentCore.AspNetCore.Endpoints.IEntrySelector.SelectAsync(Microsoft.AspNetCore.Http.HttpContext! http, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask<string?>
static AgentCore.AspNetCore.Endpoints.AgentCoreEntries.ResolveAsync(Microsoft.AspNetCore.Http.HttpContext! http, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask<string?>
static AgentCore.AspNetCore.Endpoints.EntrySelectorEndpointConventionBuilderExtensions.SelectEntry<TSelector>(this Microsoft.AspNetCore.Builder.IEndpointConventionBuilder! builder) -> Microsoft.AspNetCore.Builder.IEndpointConventionBuilder!
Loading
Loading