Skip to content
Draft
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 @@ -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,50 @@ 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 '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)
{
// 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<AIAgent>(agentName);

// Create options with properties from the request
var chatOptions = new ChatOptions
Expand All @@ -54,7 +90,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 @@ -75,41 +111,24 @@ public async IAsyncEnumerable<StreamingResponseEvent> ExecuteAsync(
}

/// <summary>
/// Resolves an agent from the service provider based on the request.
/// Extracts the agent name from the request, prioritizing agent.name over model.
/// </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, or null if not specified.</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.");
}

// 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);
}
return request.Agent?.Name ?? request.Model;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you remind me why DevUI doesn't just always send it in an agent property rather than using model for that?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DevUI uses the official OpenAI SDK, which doesn't have the 'agent' property. I want to introduce a change there to use the 'metadata' dictionary instead of 'model', but I want to un-block @jeffhandley etc asap

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DevUI uses the official OpenAI SDK, which doesn't have the 'agent' property

But it does now have JSON Patch support, so you can get/set whatever properties you want to a request.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do all of the OpenAI SDKs support setting arbitrary properties in requests & responses?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have an alternate PR here that uses metadata.entity_id instead: #1984

}

/// <summary>
/// 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.
/// </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)
/// <returns>The model ID to use, or null if model parameter is being used for agent identification.</returns>
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;
}
}
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 @@ -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