Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 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 @@ -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<string>(workflow, input);
await using StreamingRun run = await InProcessExecution.StreamAsync(workflow, input);

// Watch the workflow events
await foreach (WorkflowEvent evt in run.WatchStreamAsync())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>(workflow, input);
await using StreamingRun run = await InProcessExecution.StreamAsync(workflow, input);

// Watch the workflow events
await foreach (WorkflowEvent evt in run.WatchStreamAsync())
Expand Down
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 @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,14 +37,74 @@ 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 = request.Agent?.Name ?? request.Model;

if (string.IsNullOrEmpty(agentName))
{
return ValueTask.FromResult<ResponseError?>(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<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)
{
// Extract agent name from agent.name or model parameter
string? model;
if (request.Agent?.Name is { } agentName)
{
model = request.Model;
}
else
{
// If the model is being used for the agent name, do not also use it for the model.
agentName = request.Model;
model = null;
}

if (string.IsNullOrEmpty(agentName))
{
throw new InvalidOperationException("No 'agent.name' or 'model' specified in the request.");
}

// Validate and resolve agent synchronously to ensure validation errors are thrown immediately
AIAgent agent = this.ResolveAgent(request);
AIAgent agent;
try
{
// Resolve the keyed agent service
agent = this._serviceProvider.GetRequiredKeyedService<AIAgent>(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);
}

// Create options with properties from the request
var chatOptions = new ChatOptions
Expand All @@ -54,7 +114,7 @@ public async IAsyncEnumerable<StreamingResponseEvent> ExecuteAsync(
TopP = (float?)request.TopP,
MaxOutputTokens = request.MaxOutputTokens,
Instructions = request.Instructions,
ModelId = request.Model,
ModelId = model,
};
var options = new ChatClientAgentRunOptions(chatOptions);

Expand All @@ -73,43 +133,4 @@ public async IAsyncEnumerable<StreamingResponseEvent> ExecuteAsync(
yield return streamingEvent;
}
}

/// <summary>
/// Resolves an agent from the service provider based on the request.
/// </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)
{
// 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<AIAgent>(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);
}
}

/// <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);
}
}
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 @@
[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,20 @@
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 => Results.InternalServerError(response.Error),

Check failure on line 75 in dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/ResponsesHttpHandler.cs

View workflow job for this annotation

GitHub Actions / dotnet-build-and-test (net9.0, ubuntu-latest, Release, true, integration)

'Results' does not contain a definition for 'InternalServerError'

Check failure on line 75 in dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/ResponsesHttpHandler.cs

View workflow job for this annotation

GitHub Actions / dotnet-build-and-test (net9.0, ubuntu-latest, Release, true, integration)

'Results' does not contain a definition for 'InternalServerError'

Check failure on line 75 in dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/ResponsesHttpHandler.cs

View workflow job for this annotation

GitHub Actions / dotnet-build-and-test (net9.0, windows-latest, Release)

'Results' does not contain a definition for 'InternalServerError'

Check failure on line 75 in dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/ResponsesHttpHandler.cs

View workflow job for this annotation

GitHub Actions / dotnet-build-and-test (net9.0, windows-latest, Release)

'Results' does not contain a definition for 'InternalServerError'

Check failure on line 75 in dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/ResponsesHttpHandler.cs

View workflow job for this annotation

GitHub Actions / dotnet-build-and-test (net472, windows-latest, Release, true, integration)

'Results' does not contain a definition for 'InternalServerError'

Check failure on line 75 in dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/ResponsesHttpHandler.cs

View workflow job for this annotation

GitHub Actions / dotnet-build-and-test (net472, windows-latest, Release, true, integration)

'Results' does not contain a definition for 'InternalServerError'
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 @@ -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);
Expand Down
Loading