Skip to content
Open
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
3 changes: 3 additions & 0 deletions dotnet/eng/MSBuild/Shared.props
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@
<ItemGroup Condition="'$(InjectSharedOriginPinning)' == 'true'">
<Compile Include="$(MSBuildThisFileDirectory)\..\..\src\Shared\OriginPinning\*.cs" LinkBase="Shared\OriginPinning" />
</ItemGroup>
<ItemGroup Condition="'$(InjectSharedHostedAgents)' == 'true'">
<Compile Include="$(MSBuildThisFileDirectory)\..\..\src\Shared\HostedAgents\*.cs" LinkBase="Shared\HostedAgents" />
</ItemGroup>
<ItemGroup Condition="'$(InjectSharedHttpHeaderValidation)' == 'true'">
<Compile Include="$(MSBuildThisFileDirectory)\..\..\src\Shared\Http\*.cs" LinkBase="Shared\Http" />
</ItemGroup>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ public static IEndpointConventionBuilder MapOpenAIResponses(this IEndpointRouteB
/// <param name="path">Custom route path for the OpenAI Responses endpoint.</param>
/// <param name="mapOptions">Optional options controlling how incoming requests are mapped onto the agent run.</param>
/// <remarks>
/// Singleton registrations are resolved once when the endpoint is mapped. Scoped and transient registrations are
/// resolved for each validation or execution operation. The keyed session store is always resolved for each operation,
/// and persisted sessions are partitioned by the registration name.
/// See <see cref="MapOpenAIResponses(IEndpointRouteBuilder, string)"/> for endpoint authorization
/// and caller isolation requirements.
/// </remarks>
Expand All @@ -46,21 +49,24 @@ public static IEndpointConventionBuilder MapOpenAIResponses(this IEndpointRouteB
ArgumentNullException.ThrowIfNull(endpoints);
ArgumentNullException.ThrowIfNull(agentBuilder);

ValidateAgentName(agentBuilder.Name);
path ??= $"/{agentBuilder.Name}/v1/responses";
string agentName = agentBuilder.Name;
ValidateAgentName(agentName);
path ??= $"/{agentName}/v1/responses";

// Defer agent and session-store resolution so their registered lifetimes are owned by
// each validation or execution operation rather than by the application root.
var scopeFactory = endpoints.ServiceProvider.GetRequiredService<IServiceScopeFactory>();
var executor = new AIAgentResponseExecutor(
agentBuilder.Name,
scopeFactory,
mapOptions);
var executor = agentBuilder.Lifetime == ServiceLifetime.Singleton
? new AIAgentResponseExecutor(
endpoints.ServiceProvider.GetRequiredKeyedService<AIAgent>(agentName),
agentName,
scopeFactory,
mapOptions,
sessionStorageIdentity: agentName)
: new AIAgentResponseExecutor(agentName, scopeFactory, mapOptions);
return MapOpenAIResponses(
endpoints,
executor,
path,
agentBuilder.Name);
agentName);
}

/// <summary>
Expand Down Expand Up @@ -114,11 +120,80 @@ public static IEndpointConventionBuilder MapOpenAIResponses(
agent.Name);
}

/// <summary>
/// Maps OpenAI Responses API endpoints that select the <see cref="AIAgent"/> for each request.
/// </summary>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the OpenAI Responses endpoints to.</param>
/// <param name="responsesPath">The route path for the responses endpoint.</param>
/// <param name="agentSelector">
/// A delegate invoked once per request that returns the agent to serve, or <see langword="null"/> when
/// no agent matches the request. The delegate can use route values, claims, headers, and
/// <see cref="HttpContext.RequestServices"/> to select or resolve the agent.
/// </param>
/// <param name="mapOptions">Optional options controlling how incoming requests are mapped onto the agent run.</param>
/// <returns>An <see cref="IEndpointConventionBuilder"/> for further endpoint configuration.</returns>
/// <remarks>
/// <para>
/// The selector applies to every operation: creation, retrieval, cancellation, deletion, and input item listing.
/// When <paramref name="agentSelector"/> returns <see langword="null"/>, the operation responds with
/// <c>404 Not Found</c> before any response, session, or conversation state is loaded.
/// </para>
/// <para>
/// The selected agent's <see cref="AIAgent.Name"/> is its stable logical identity: it is used to resolve the
/// keyed <see cref="AgentSessionStore"/>, to partition persisted sessions, and to bind stored responses to the
/// agent that created them, so a response can only be retrieved, canceled, or deleted through requests that select
/// the same agent name. Conversations continue across requests even when the selector returns a new agent instance
/// each time. The selected agent must therefore have a non-empty name.
/// </para>
/// <para>
/// The keyed <see cref="AgentSessionStore"/> is resolved from <see cref="HttpContext.RequestServices"/> for
/// validation and for responses that are not run in the background, so it shares the request-scoped dependencies of
/// an agent resolved from the same services. These responses are bound to the request: a streaming response keeps
/// running when the client disconnects, and the request ends only after the run completes, while a non-streaming
/// response is canceled with the request and the request ends after the run stops. Background responses keep
/// running the selected agent after the HTTP request completes and resolve the session store from a separate service
/// scope that they own, so agents used for background responses must not depend on request-scoped services.
/// </para>
/// <para>
/// Selection is not an authorization boundary. See <see cref="MapOpenAIResponses(IEndpointRouteBuilder, string)"/>
/// for endpoint authorization and caller isolation requirements.
/// </para>
/// </remarks>
public static IEndpointConventionBuilder MapOpenAIResponses(
this IEndpointRouteBuilder endpoints,
[StringSyntax("Route")] string responsesPath,
Func<HttpContext, AIAgent?> agentSelector,
OpenAIResponsesMapOptions? mapOptions = null)
{
ArgumentNullException.ThrowIfNull(endpoints);
ArgumentException.ThrowIfNullOrWhiteSpace(responsesPath);
ArgumentNullException.ThrowIfNull(agentSelector);

var scopeFactory = endpoints.ServiceProvider.GetRequiredService<IServiceScopeFactory>();
return MapOpenAIResponses(
endpoints,
executor: null,
responsesPath,
endpointAgentName: null,
context =>
{
if (agentSelector(context) is not { } agent)
{
return null;
}

string agentName = HostedAgentResolution.GetSelectedAgentName(agent);
return (agentName, new AIAgentResponseExecutor(
agent, agentName, scopeFactory, mapOptions, sessionStorageIdentity: agentName, requestServices: context.RequestServices));
});
}

private static RouteGroupBuilder MapOpenAIResponses(
IEndpointRouteBuilder endpoints,
IResponseExecutor executor,
IResponseExecutor? executor,
string responsesPath,
string endpointAgentName)
string? endpointAgentName,
Func<HttpContext, (string AgentName, IResponseExecutor Executor)?>? agentSelector = null)
{
// Resolve the response storage settings and optional conversation storage.
var storageOptions = endpoints.ServiceProvider.GetService<InMemoryStorageOptions>() ?? new InMemoryStorageOptions();
Expand All @@ -132,38 +207,46 @@ private static RouteGroupBuilder MapOpenAIResponses(

// Create the response service so response and conversation operations are scoped by the caller's isolation key.
var responsesService = new InMemoryResponsesService(executor, storageOptions, conversationStorage, isolationKeyResolver);
var handlers = new ResponsesHttpHandler(responsesService);
var handlers = new ResponsesHttpHandler(responsesService, agentSelector is null ? null : SelectAgent);
var group = endpoints.MapGroup(responsesPath);

// Create response endpoint
group.MapPost("/", handlers.CreateResponseAsync)
.WithName(endpointAgentName + "/CreateResponse")
.WithEndpointName(endpointAgentName, "CreateResponse")
.WithSummary("Creates a model response for the given input");

// Get response endpoint
group.MapGet("{responseId}", handlers.GetResponseAsync)
.WithName(endpointAgentName + "/GetResponse")
.WithEndpointName(endpointAgentName, "GetResponse")
.WithSummary("Retrieves a response by ID");

// Cancel response endpoint
group.MapPost("{responseId}/cancel", handlers.CancelResponseAsync)
.WithName(endpointAgentName + "/CancelResponse")
.WithEndpointName(endpointAgentName, "CancelResponse")
.WithSummary("Cancels an in-progress response");

// Delete response endpoint
group.MapDelete("{responseId}", handlers.DeleteResponseAsync)
.WithName(endpointAgentName + "/DeleteResponse")
.WithEndpointName(endpointAgentName, "DeleteResponse")
.WithSummary("Deletes a response");

// List response input items endpoint
group.MapGet("{responseId}/input_items", handlers.ListResponseInputItemsAsync)
.WithName(endpointAgentName + "/ListResponseInputItems")
.WithEndpointName(endpointAgentName, "ListResponseInputItems")
.WithSummary("Lists the input items for a response");

MarkFeatureUsed();
return group;

(IResponsesService, IResponseExecutor)? SelectAgent(HttpContext context) =>
agentSelector(context) is { } selection
? (responsesService.ForAgent(selection.AgentName), selection.Executor)
: null;
}

private static RouteHandlerBuilder WithEndpointName(this RouteHandlerBuilder builder, string? endpointAgentName, string operationName)
=> endpointAgentName is null ? builder : builder.WithName(endpointAgentName + "/" + operationName);

/// <summary>
/// Maps OpenAI Responses API endpoints to the specified <see cref="IEndpointRouteBuilder"/>.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
<PropertyGroup>
<InjectSharedDiagnosticIds>true</InjectSharedDiagnosticIds>
<InjectSharedThrow>true</InjectSharedThrow>
<InjectSharedHostedAgents>true</InjectSharedHostedAgents>
</PropertyGroup>

<ItemGroup>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,22 +19,43 @@ internal sealed class AIAgentResponseExecutor : IResponseExecutor
{
private readonly AIAgent? _agent;
private readonly string _registrationKey;
private readonly string? _sessionStorageIdentity;
private readonly IServiceScopeFactory _scopeFactory;
private readonly IServiceProvider? _requestServices;
private readonly OpenAIResponsesMapOptions _mapOptions;

/// <summary>
/// Initializes a new instance of the <see cref="AIAgentResponseExecutor"/> class for a fixed agent instance.
/// </summary>
/// <param name="agent">The agent that executes the responses.</param>
/// <param name="registrationKey">The name that keys the agent's session store.</param>
/// <param name="scopeFactory">The factory used to create a scope for operations that are not bound to a request.</param>
/// <param name="mapOptions">Optional options controlling how incoming requests are mapped onto the agent run.</param>
/// <param name="sessionStorageIdentity">
/// The stable logical identity that partitions persisted sessions, or <see langword="null"/> to use the agent instance identity.
/// </param>
/// <param name="requestServices">
/// The services of the request that selected <paramref name="agent"/>, or <see langword="null"/> when the agent is not
/// bound to a request. When specified, validation and responses that are not run in the background resolve the
/// session store from these services, so the store shares the agent's request-scoped dependencies.
/// </param>
public AIAgentResponseExecutor(
AIAgent agent,
string registrationKey,
IServiceScopeFactory scopeFactory,
OpenAIResponsesMapOptions? mapOptions = null)
OpenAIResponsesMapOptions? mapOptions = null,
string? sessionStorageIdentity = null,
IServiceProvider? requestServices = null)
{
ArgumentNullException.ThrowIfNull(agent);
ArgumentException.ThrowIfNullOrEmpty(registrationKey);
ArgumentNullException.ThrowIfNull(scopeFactory);

this._agent = agent;
this._registrationKey = registrationKey;
this._sessionStorageIdentity = sessionStorageIdentity;
this._scopeFactory = scopeFactory;
this._requestServices = requestServices;
this._mapOptions = mapOptions ?? new OpenAIResponsesMapOptions();
}

Expand All @@ -47,6 +68,7 @@ public AIAgentResponseExecutor(
ArgumentNullException.ThrowIfNull(scopeFactory);

this._registrationKey = registrationKey;
this._sessionStorageIdentity = registrationKey;
this._scopeFactory = scopeFactory;
this._mapOptions = mapOptions ?? new OpenAIResponsesMapOptions();
}
Expand All @@ -55,19 +77,33 @@ public AIAgentResponseExecutor(
CreateResponse request,
CancellationToken cancellationToken = default)
{
// Validation always runs within the request.
if (this._requestServices is { } requestServices)
{
return await this.ValidateRequestAsync(request, requestServices, cancellationToken).ConfigureAwait(false);
}

AsyncServiceScope scope = this._scopeFactory.CreateAsyncScope();
await using (scope.ConfigureAwait(false))
{
AIAgent executionAgent = this.ResolveExecutionAgent(scope.ServiceProvider);
ResponseError? sessionError =
AgentResponseExecution.ValidateSessionRequirements(request, executionAgent is AIHostAgent);
return sessionError
?? this.ValidateRunOptions(request)
?? await AgentResponseExecution.ValidatePendingApprovalResponsesAsync(
executionAgent, request, cancellationToken).ConfigureAwait(false);
return await this.ValidateRequestAsync(request, scope.ServiceProvider, cancellationToken).ConfigureAwait(false);
}
}

private async ValueTask<ResponseError?> ValidateRequestAsync(
CreateResponse request,
IServiceProvider services,
CancellationToken cancellationToken)
{
AIAgent executionAgent = this.ResolveExecutionAgent(services);
ResponseError? sessionError =
AgentResponseExecution.ValidateSessionRequirements(request, executionAgent is AIHostAgent);
return sessionError
?? this.ValidateRunOptions(request)
?? await AgentResponseExecution.ValidatePendingApprovalResponsesAsync(
executionAgent, request, cancellationToken).ConfigureAwait(false);
}

internal ResponseError? ValidateRunOptions(CreateResponse request)
{
try
Expand All @@ -93,23 +129,55 @@ public async IAsyncEnumerable<StreamingResponseEvent> ExecuteAsync(
IReadOnlyList<ChatMessage>? conversationHistory = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
// Responses that are not run in the background are owned by the request that selected the agent, so their
// session store shares the agent's request-scoped dependencies.
if (this._requestServices is { } requestServices && this.IsExecutionBoundToRequest(request))
{
await foreach (StreamingResponseEvent streamingEvent in this.ExecuteAsync(
requestServices, context, request, conversationHistory, cancellationToken).ConfigureAwait(false))
{
yield return streamingEvent;
}

yield break;
}

// The scope surrounds the full enumeration so background execution retains scoped
// agents and stores through session persistence after the HTTP request has returned.
AsyncServiceScope scope = this._scopeFactory.CreateAsyncScope();
await using (scope.ConfigureAwait(false))
{
AIAgent executionAgent = this.ResolveExecutionAgent(scope.ServiceProvider);

// Agent selection is fixed by the endpoint. The remaining response execution behavior is
// shared with request-routed hosted agents.
await foreach (StreamingResponseEvent streamingEvent in AgentResponseExecution.ExecuteAsync(
executionAgent, this._mapOptions, context, request, conversationHistory, cancellationToken).ConfigureAwait(false))
await foreach (StreamingResponseEvent streamingEvent in this.ExecuteAsync(
scope.ServiceProvider, context, request, conversationHistory, cancellationToken).ConfigureAwait(false))
{
yield return streamingEvent;
}
}
}

private IAsyncEnumerable<StreamingResponseEvent> ExecuteAsync(
IServiceProvider services,
AgentInvocationContext context,
CreateResponse request,
IReadOnlyList<ChatMessage>? conversationHistory,
CancellationToken cancellationToken)
{
AIAgent executionAgent = this.ResolveExecutionAgent(services);

// Agent selection is fixed by the endpoint. The remaining response execution behavior is
// shared with request-routed hosted agents.
return AgentResponseExecution.ExecuteAsync(
executionAgent, this._mapOptions, context, request, conversationHistory, cancellationToken);
}

/// <inheritdoc/>
/// <remarks>
/// Executions for an agent selected by a request use that request's services unless they run in the background,
/// which outlives the request and therefore owns a separate service scope.
/// </remarks>
public bool IsExecutionBoundToRequest(CreateResponse request) =>
this._requestServices is not null && request.Background != true;

private AIAgent ResolveExecutionAgent(IServiceProvider services)
{
AIAgent agent = this._agent ??
Expand All @@ -123,7 +191,6 @@ private AIAgent ResolveExecutionAgent(IServiceProvider services)
return agent;
}

string? sessionStorageIdentity = this._agent is null ? this._registrationKey : null;
return new AIHostAgent(agent, sessionStore, sessionStorageIdentity);
return new AIHostAgent(agent, sessionStore, this._sessionStorageIdentity);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,15 @@ IAsyncEnumerable<StreamingResponseEvent> ExecuteAsync(
CreateResponse request,
IReadOnlyList<ChatMessage>? conversationHistory = null,
CancellationToken cancellationToken = default);

/// <summary>
/// Gets a value indicating whether the execution of <paramref name="request"/> uses services owned by the HTTP request
/// that creates the response.
/// </summary>
/// <param name="request">The create response request.</param>
/// <returns>
/// <see langword="true"/> when the request must not end before the execution completes; <see langword="false"/> when
/// the execution owns the services it uses and can outlive the request.
/// </returns>
bool IsExecutionBoundToRequest(CreateResponse request) => false;
}
Loading
Loading