diff --git a/dotnet/samples/GettingStarted/Workflows/_Foundational/07_MixedWorkflowAgentsAndExecutors/Program.cs b/dotnet/samples/GettingStarted/Workflows/_Foundational/07_MixedWorkflowAgentsAndExecutors/Program.cs index c5437a5809..f665c1b817 100644 --- a/dotnet/samples/GettingStarted/Workflows/_Foundational/07_MixedWorkflowAgentsAndExecutors/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/_Foundational/07_MixedWorkflowAgentsAndExecutors/Program.cs @@ -132,7 +132,7 @@ private static async Task ExecuteWorkflowAsync(Workflow workflow, string input) const bool ShowAgentThinking = false; // Execute in streaming mode to see real-time progress - await using StreamingRun run = await InProcessExecution.StreamAsync(workflow, input); + await using StreamingRun run = await InProcessExecution.StreamAsync(workflow, input); // Watch the workflow events await foreach (WorkflowEvent evt in run.WatchStreamAsync()) diff --git a/dotnet/samples/GettingStarted/Workflows/_Foundational/08_WriterCriticWorkflow/Program.cs b/dotnet/samples/GettingStarted/Workflows/_Foundational/08_WriterCriticWorkflow/Program.cs index fc39044b42..d9cc30f395 100644 --- a/dotnet/samples/GettingStarted/Workflows/_Foundational/08_WriterCriticWorkflow/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/_Foundational/08_WriterCriticWorkflow/Program.cs @@ -89,7 +89,7 @@ private static async Task Main() private static async Task ExecuteWorkflowAsync(Workflow workflow, string input) { // Execute in streaming mode to see real-time progress - await using StreamingRun run = await InProcessExecution.StreamAsync(workflow, input); + await using StreamingRun run = await InProcessExecution.StreamAsync(workflow, input); // Watch the workflow events await foreach (WorkflowEvent evt in run.WatchStreamAsync()) diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/AIAgentResponseExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/AIAgentResponseExecutor.cs index 18863034bf..8b909651b9 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/AIAgentResponseExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/AIAgentResponseExecutor.cs @@ -24,6 +24,10 @@ public AIAgentResponseExecutor(AIAgent agent) this._agent = agent; } + public ValueTask ValidateRequestAsync( + CreateResponse request, + CancellationToken cancellationToken = default) => ValueTask.FromResult(null); + public async IAsyncEnumerable ExecuteAsync( AgentInvocationContext context, CreateResponse request, diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/AgentRunResponseExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/AgentRunResponseExtensions.cs index fedaeae1f4..820c2c91c6 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/AgentRunResponseExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/AgentRunResponseExtensions.cs @@ -64,7 +64,7 @@ public static Response ToResponse( 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, diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/HostedAgentResponseExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/HostedAgentResponseExecutor.cs index 78e4331b6b..5631722afc 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/HostedAgentResponseExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/HostedAgentResponseExecutor.cs @@ -37,14 +37,50 @@ public HostedAgentResponseExecutor( this._logger = logger; } + /// + public ValueTask 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(new ResponseError + { + Code = "missing_required_parameter", + Message = "No 'agent.name' or 'model' specified in the request." + }); + } + + // Validate that the agent can be resolved + AIAgent? agent = this._serviceProvider.GetKeyedService(agentName); + if (agent is null) + { + this._logger.LogWarning("Failed to resolve agent with name '{AgentName}'", agentName); + return ValueTask.FromResult(new ResponseError + { + Code = "agent_not_found", + Message = $"Agent '{agentName}' not found. Ensure the agent is registered with AddAIAgent()." + }); + } + + return ValueTask.FromResult(null); + } + /// public async IAsyncEnumerable 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); + // Extract agent name and model from request (validation already done in ValidateRequestAsync) + string agentName = GetAgentName(request)!; + string? model = GetModelId(request); + + // Resolve the keyed agent service (validation guarantees this succeeds) + AIAgent agent = this._serviceProvider.GetRequiredKeyedService(agentName); // Create options with properties from the request var chatOptions = new ChatOptions @@ -54,7 +90,7 @@ public async IAsyncEnumerable ExecuteAsync( TopP = (float?)request.TopP, MaxOutputTokens = request.MaxOutputTokens, Instructions = request.Instructions, - ModelId = request.Model, + ModelId = model, }; var options = new ChatClientAgentRunOptions(chatOptions); @@ -75,41 +111,24 @@ public async IAsyncEnumerable ExecuteAsync( } /// - /// Resolves an agent from the service provider based on the request. + /// Extracts the agent name from the request, prioritizing agent.name over model. /// /// The create response request. - /// The resolved AIAgent instance. - /// Thrown when the agent cannot be resolved. - private AIAgent ResolveAgent(CreateResponse request) + /// The agent name, or null if not specified. + 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."); - } - - // Resolve the keyed agent service - try - { - return this._serviceProvider.GetRequiredKeyedService(agentName); - } - 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); - } + return request.Agent?.Name ?? request.Model; } /// - /// Validates that the agent can be resolved without actually resolving it. - /// This allows early validation before starting async execution. + /// Extracts the model ID from the request. Returns null if model is being used as agent name. /// /// The create response request. - /// Thrown when the agent cannot be resolved. - public void ValidateAgent(CreateResponse request) + /// The model ID to use, or null if model parameter is being used for agent identification. + private static string? GetModelId(CreateResponse request) { - // Use the same logic as ResolveAgent but don't return the agent - _ = this.ResolveAgent(request); + // If agent.name is specified, use model as the model ID + // Otherwise, model is being used for agent name, so return null + return request.Agent?.Name is not null ? request.Model : null; } } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/IResponseExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/IResponseExecutor.cs index ca4da70b88..b96879f4cc 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/IResponseExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/IResponseExecutor.cs @@ -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; @@ -12,6 +13,16 @@ namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses; /// internal interface IResponseExecutor { + /// + /// Validates a create response request before execution. + /// + /// The create response request to validate. + /// Cancellation token. + /// A if validation fails, null if validation succeeds. + ValueTask ValidateRequestAsync( + CreateResponse request, + CancellationToken cancellationToken = default); + /// /// Executes a response generation request and returns streaming events. /// diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/IResponsesService.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/IResponsesService.cs index 67f7b72f20..b1676ac99c 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/IResponsesService.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/IResponsesService.cs @@ -18,6 +18,17 @@ internal interface IResponsesService /// Default limit for list operations. /// const int DefaultListLimit = 20; + + /// + /// Validates a create response request before execution. + /// + /// The create response request to validate. + /// Cancellation token. + /// A ResponseError if validation fails, null if validation succeeds. + ValueTask ValidateRequestAsync( + CreateResponse request, + CancellationToken cancellationToken = default); + /// /// Creates a model response for the given input. /// diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/InMemoryResponsesService.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/InMemoryResponsesService.cs index dfb744596a..2f5b3f4660 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/InMemoryResponsesService.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/InMemoryResponsesService.cs @@ -147,18 +147,27 @@ public InMemoryResponsesService(IResponseExecutor executor, InMemoryStorageOptio this._conversationStorage = conversationStorage; } - public async Task CreateResponseAsync( + public async ValueTask 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 CreateResponseAsync( + CreateResponse request, + CancellationToken cancellationToken = default) + { if (request.Stream == true) { throw new InvalidOperationException("Cannot create a streaming response using CreateResponseAsync. Use CreateResponseStreamingAsync instead."); @@ -189,8 +198,6 @@ public async IAsyncEnumerable 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."); @@ -342,15 +349,6 @@ public Task> 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 ?? []; @@ -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, diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/ResponsesHttpHandler.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/ResponsesHttpHandler.cs index 31f61e967e..b73cdebda5 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/ResponsesHttpHandler.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/ResponsesHttpHandler.cs @@ -34,6 +34,21 @@ public async Task 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 @@ -55,45 +70,24 @@ public async Task 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"); } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesAgentResolutionIntegrationTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesAgentResolutionIntegrationTests.cs index c8ce5b770e..ec1544de1c 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesAgentResolutionIntegrationTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesAgentResolutionIntegrationTests.cs @@ -235,7 +235,7 @@ public async Task CreateResponse_WithNonExistentAgent_ReturnsNotFoundAsync() using HttpResponseMessage httpResponse = await this._httpClient!.PostAsync(new Uri("/v1/responses", UriKind.Relative), requestContent); // Assert - Assert.Equal(System.Net.HttpStatusCode.NotFound, httpResponse.StatusCode); + Assert.Equal(System.Net.HttpStatusCode.BadRequest, httpResponse.StatusCode); string responseJson = await httpResponse.Content.ReadAsStringAsync(); Assert.Contains("non-existent-agent", responseJson);