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 @@ -24,6 +24,10 @@ public AIAgentResponseExecutor(AIAgent agent)
this._agent = agent;
}

public ValueTask<ResponseError?> ValidateRequestAsync(
CreateResponse request,
CancellationToken cancellationToken = default) => ValueTask.FromResult<ResponseError?>(null);

public async IAsyncEnumerable<StreamingResponseEvent> ExecuteAsync(
AgentInvocationContext context,
CreateResponse request,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,15 +56,15 @@ public static Response ToResponse(
MaxOutputTokens = request.MaxOutputTokens,
MaxToolCalls = request.MaxToolCalls,
Metadata = request.Metadata is IReadOnlyDictionary<string, string> metadata ? new Dictionary<string, string>(metadata) : [],
Model = request.Agent?.Name ?? request.Model,
Model = request.Model,
Output = output,
ParallelToolCalls = request.ParallelToolCalls ?? true,
PreviousResponseId = request.PreviousResponseId,
Prompt = request.Prompt,
PromptCacheKey = request.PromptCacheKey,
Reasoning = request.Reasoning,
SafetyIdentifier = request.SafetyIdentifier,
ServiceTier = request.ServiceTier ?? "default",
ServiceTier = request.ServiceTier,
Status = ResponseStatus.Completed,
Store = request.Store ?? true,
Temperature = request.Temperature ?? 1.0,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ Response CreateResponse(ResponseStatus status = ResponseStatus.Completed, IEnume
MaxOutputTokens = request.MaxOutputTokens,
MaxToolCalls = request.MaxToolCalls,
Metadata = request.Metadata != null ? new Dictionary<string, string>(request.Metadata) : [],
Model = request.Agent?.Name ?? request.Model,
Model = request.Model,
Output = outputs?.ToList() ?? [],
ParallelToolCalls = request.ParallelToolCalls ?? true,
PreviousResponseId = request.PreviousResponseId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@
namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses;

/// <summary>
/// Response executor that routes requests to hosted AIAgent services based on the model or agent.name parameter.
/// Response executor that routes requests to hosted AIAgent services based on agent.name or metadata["entity_id"].
/// This executor resolves agents from keyed services registered via AddAIAgent().
/// The model field is reserved for actual model names and is never used for entity/agent identification.
/// </summary>
internal sealed class HostedAgentResponseExecutor : IResponseExecutor
{
Expand All @@ -37,16 +38,46 @@ public HostedAgentResponseExecutor(
this._logger = logger;
}

/// <inheritdoc/>
public ValueTask<ResponseError?> ValidateRequestAsync(
CreateResponse request,
CancellationToken cancellationToken = default)
{
// Extract agent name from agent.name or model parameter
string? agentName = GetAgentName(request);

if (string.IsNullOrEmpty(agentName))
{
return ValueTask.FromResult<ResponseError?>(new ResponseError
{
Code = "missing_required_parameter",
Message = "No 'agent.name' or 'metadata[\"entity_id\"]' specified in the request."
});
}

// Validate that the agent can be resolved
AIAgent? agent = this._serviceProvider.GetKeyedService<AIAgent>(agentName);
if (agent is null)
{
this._logger.LogWarning("Failed to resolve agent with name '{AgentName}'", agentName);
return ValueTask.FromResult<ResponseError?>(new ResponseError
{
Code = "agent_not_found",
Message = $"Agent '{agentName}' not found. Ensure the agent is registered with AddAIAgent()."
});
}

return ValueTask.FromResult<ResponseError?>(null);
}

/// <inheritdoc/>
public async IAsyncEnumerable<StreamingResponseEvent> ExecuteAsync(
AgentInvocationContext context,
CreateResponse request,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
// Validate and resolve agent synchronously to ensure validation errors are thrown immediately
AIAgent agent = this.ResolveAgent(request);

// Create options with properties from the request
string agentName = GetAgentName(request)!;
AIAgent agent = this._serviceProvider.GetRequiredKeyedService<AIAgent>(agentName);
var chatOptions = new ChatOptions
{
ConversationId = request.Conversation?.Id,
Expand All @@ -57,16 +88,13 @@ public async IAsyncEnumerable<StreamingResponseEvent> ExecuteAsync(
ModelId = request.Model,
};
var options = new ChatClientAgentRunOptions(chatOptions);

// Convert input to chat messages
var messages = new List<ChatMessage>();

foreach (var inputMessage in request.Input.GetInputMessages())
{
messages.Add(inputMessage.ToChatMessage());
}

// Use the extension method to convert streaming updates to streaming response events
await foreach (var streamingEvent in agent.RunStreamingAsync(messages, options: options, cancellationToken: cancellationToken)
.ToStreamingResponseAsync(request, context, cancellationToken).ConfigureAwait(false))
{
Expand All @@ -75,41 +103,20 @@ public async IAsyncEnumerable<StreamingResponseEvent> ExecuteAsync(
}

/// <summary>
/// Resolves an agent from the service provider based on the request.
/// Extracts the agent name for a request from the agent.name property, falling back to metadata["entity_id"].
/// </summary>
/// <param name="request">The create response request.</param>
/// <returns>The resolved AIAgent instance.</returns>
/// <exception cref="InvalidOperationException">Thrown when the agent cannot be resolved.</exception>
private AIAgent ResolveAgent(CreateResponse request)
/// <returns>The agent name.</returns>
private static string? GetAgentName(CreateResponse request)
{
// Extract agent name from agent.name or model parameter
var agentName = request.Agent?.Name ?? request.Model;
if (string.IsNullOrEmpty(agentName))
{
throw new InvalidOperationException("No 'agent.name' or 'model' specified in the request.");
}
string? agentName = request.Agent?.Name;

// Resolve the keyed agent service
try
// Fall back to metadata["entity_id"] if agent.name is not present
if (string.IsNullOrEmpty(agentName) && request.Metadata?.TryGetValue("entity_id", out string? entityId) == true)
{
return this._serviceProvider.GetRequiredKeyedService<AIAgent>(agentName);
agentName = entityId;
}
catch (InvalidOperationException ex)
{
this._logger.LogError(ex, "Failed to resolve agent with name '{AgentName}'", agentName);
throw new InvalidOperationException($"Agent '{agentName}' not found. Ensure the agent is registered with AddAIAgent().", ex);
}
}

/// <summary>
/// Validates that the agent can be resolved without actually resolving it.
/// This allows early validation before starting async execution.
/// </summary>
/// <param name="request">The create response request.</param>
/// <exception cref="InvalidOperationException">Thrown when the agent cannot be resolved.</exception>
public void ValidateAgent(CreateResponse request)
{
// Use the same logic as ResolveAgent but don't return the agent
_ = this.ResolveAgent(request);
return agentName;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Hosting.OpenAI.Responses.Models;

namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses;
Expand All @@ -12,6 +13,16 @@ namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses;
/// </summary>
internal interface IResponseExecutor
{
/// <summary>
/// Validates a create response request before execution.
/// </summary>
/// <param name="request">The create response request to validate.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A <see cref="ResponseError"/> if validation fails, null if validation succeeds.</returns>
ValueTask<ResponseError?> ValidateRequestAsync(
CreateResponse request,
CancellationToken cancellationToken = default);

/// <summary>
/// Executes a response generation request and returns streaming events.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,17 @@ internal interface IResponsesService
/// Default limit for list operations.
/// </summary>
const int DefaultListLimit = 20;

/// <summary>
/// Validates a create response request before execution.
/// </summary>
/// <param name="request">The create response request to validate.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A ResponseError if validation fails, null if validation succeeds.</returns>
ValueTask<ResponseError?> ValidateRequestAsync(
CreateResponse request,
CancellationToken cancellationToken = default);

/// <summary>
/// Creates a model response for the given input.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -147,18 +147,27 @@ public InMemoryResponsesService(IResponseExecutor executor, InMemoryStorageOptio
this._conversationStorage = conversationStorage;
}

public async Task<Response> CreateResponseAsync(
public async ValueTask<ResponseError?> ValidateRequestAsync(
CreateResponse request,
CancellationToken cancellationToken = default)
{
ValidateRequest(request);

// Validate agent resolution early for HostedAgentResponseExecutor
if (this._executor is HostedAgentResponseExecutor hostedExecutor)
if (request.Conversation is not null && !string.IsNullOrEmpty(request.Conversation.Id) &&
!string.IsNullOrEmpty(request.PreviousResponseId))
{
hostedExecutor.ValidateAgent(request);
return new ResponseError
{
Code = "invalid_request",
Message = "Mutually exclusive parameters: 'conversation' and 'previous_response_id'. Ensure you are only providing one of: 'previous_response_id' or 'conversation'."
};
}

return await this._executor.ValidateRequestAsync(request, cancellationToken).ConfigureAwait(false);
}

public async Task<Response> CreateResponseAsync(
CreateResponse request,
CancellationToken cancellationToken = default)
{
if (request.Stream == true)
{
throw new InvalidOperationException("Cannot create a streaming response using CreateResponseAsync. Use CreateResponseStreamingAsync instead.");
Expand Down Expand Up @@ -189,8 +198,6 @@ public async IAsyncEnumerable<StreamingResponseEvent> CreateResponseStreamingAsy
CreateResponse request,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
ValidateRequest(request);

if (request.Stream == false)
{
throw new InvalidOperationException("Cannot create a non-streaming response using CreateResponseStreamingAsync. Use CreateResponseAsync instead.");
Expand Down Expand Up @@ -342,15 +349,6 @@ public Task<ListResponse<ItemResource>> ListResponseInputItemsAsync(
});
}

private static void ValidateRequest(CreateResponse request)
{
if (request.Conversation is not null && !string.IsNullOrEmpty(request.Conversation.Id) &&
!string.IsNullOrEmpty(request.PreviousResponseId))
{
throw new InvalidOperationException("Mutually exclusive parameters: 'conversation' and 'previous_response_id'. Ensure you are only providing one of: 'previous_response_id' or 'conversation'.");
}
}

private ResponseState InitializeResponse(string responseId, CreateResponse request)
{
var metadata = request.Metadata ?? [];
Expand All @@ -371,7 +369,7 @@ private ResponseState InitializeResponse(string responseId, CreateResponse reque
MaxOutputTokens = request.MaxOutputTokens,
MaxToolCalls = request.MaxToolCalls,
Metadata = metadata,
Model = request.Model ?? "default",
Model = request.Model,
Output = [],
ParallelToolCalls = request.ParallelToolCalls ?? true,
PreviousResponseId = request.PreviousResponseId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,21 @@ public async Task<IResult> CreateResponseAsync(
[FromQuery] bool? stream,
CancellationToken cancellationToken)
{
// Validate the request first
ResponseError? validationError = await this._responsesService.ValidateRequestAsync(request, cancellationToken).ConfigureAwait(false);
if (validationError is not null)
{
return Results.BadRequest(new ErrorResponse
{
Error = new ErrorDetails
{
Message = validationError.Message,
Type = "invalid_request_error",
Code = validationError.Code
}
});
}

try
{
// Handle streaming vs non-streaming
Expand All @@ -55,45 +70,24 @@ public async Task<IResult> CreateResponseAsync(
request,
cancellationToken: cancellationToken).ConfigureAwait(false);

return Results.Ok(response);
}
catch (InvalidOperationException ex) when (ex.Message.Contains("Mutually exclusive"))
{
// Return OpenAI-style error for mutual exclusivity violations
return Results.BadRequest(new ErrorResponse
return response.Status switch
{
Error = new ErrorDetails
{
Message = ex.Message,
Type = "invalid_request_error",
Code = "mutually_exclusive_parameters"
}
});
}
catch (InvalidOperationException ex) when (ex.Message.Contains("not found") || ex.Message.Contains("does not exist"))
{
// Return OpenAI-style error for not found errors
return Results.NotFound(new ErrorResponse
{
Error = new ErrorDetails
{
Message = ex.Message,
Type = "invalid_request_error"
}
});
ResponseStatus.Failed when response.Error is { } error => Results.Problem(
detail: error.Message,
statusCode: StatusCodes.Status500InternalServerError,
title: error.Code ?? "Internal Server Error"),
ResponseStatus.Failed => Results.Problem(),
ResponseStatus.Queued => Results.Accepted(value: response),
_ => Results.Ok(response)
};
}
catch (InvalidOperationException ex) when (ex.Message.Contains("No 'agent.name' or 'model' specified"))
catch (Exception ex)
{
// Return OpenAI-style error for missing required parameters
return Results.BadRequest(new ErrorResponse
{
Error = new ErrorDetails
{
Message = ex.Message,
Type = "invalid_request_error",
Code = "missing_required_parameter"
}
});
// Return InternalServerError for unexpected exceptions
return Results.Problem(
detail: ex.Message,
statusCode: StatusCodes.Status500InternalServerError,
title: "Internal Server Error");
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ public async Task CreateConversationAndResponse_NonStreaming_NonBackground_Updat
// Act - Create response (non-streaming, non-background)
var createResponseRequest = new
{
model = AgentName,
metadata = new { entity_id = AgentName },
conversation = conversationId,
input = UserMessage,
stream = false
Expand Down Expand Up @@ -122,7 +122,7 @@ public async Task CreateConversationAndResponse_Streaming_NonBackground_UpdatesC
// Act - Create response (streaming, non-background)
var createResponseRequest = new
{
model = AgentName,
metadata = new { entity_id = AgentName },
conversation = conversationId,
input = UserMessage,
stream = true
Expand Down Expand Up @@ -196,7 +196,7 @@ public async Task CreateConversationAndResponse_NonStreaming_Background_UpdatesC
// Act - Create response (non-streaming, background)
var createResponseRequest = new
{
model = AgentName,
metadata = new { entity_id = AgentName },
conversation = conversationId,
input = UserMessage,
stream = false,
Expand Down
Loading
Loading