Merge branch 'main' into feature-foundry-agents

This commit is contained in:
Chris
2025-11-10 08:42:17 -08:00
committed by GitHub
161 changed files with 17581 additions and 5794 deletions
@@ -4,6 +4,7 @@ using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Threading;
@@ -152,6 +153,8 @@ public sealed class AGUIChatClient : DelegatingChatClient
private sealed class AGUIChatClientHandler : IChatClient
{
private static readonly MediaTypeHeaderValue s_json = new("application/json");
private readonly AGUIHttpService _httpService;
private readonly JsonSerializerOptions _jsonSerializerOptions;
private readonly ILogger _logger;
@@ -199,6 +202,9 @@ public sealed class AGUIChatClient : DelegatingChatClient
var threadId = ExtractTemporaryThreadId(messagesList) ??
ExtractThreadIdFromOptions(options) ?? $"thread_{Guid.NewGuid():N}";
// Extract state from the last message if it contains DataContent with application/json
JsonElement state = this.ExtractAndRemoveStateFromMessages(messagesList);
// Create the input for the AGUI service
var input = new RunAgentInput
{
@@ -207,6 +213,7 @@ public sealed class AGUIChatClient : DelegatingChatClient
ThreadId = threadId,
RunId = runId,
Messages = messagesList.AsAGUIMessages(this._jsonSerializerOptions),
State = state,
};
// Add tools if provided
@@ -300,6 +307,51 @@ public sealed class AGUIChatClient : DelegatingChatClient
return threadId;
}
// Extract state from the last message's DataContent with application/json media type
// and remove that message from the list
private JsonElement ExtractAndRemoveStateFromMessages(List<ChatMessage> messagesList)
{
if (messagesList.Count == 0)
{
return default;
}
// Check the last message for state DataContent
ChatMessage lastMessage = messagesList[messagesList.Count - 1];
for (int i = 0; i < lastMessage.Contents.Count; i++)
{
if (lastMessage.Contents[i] is DataContent dataContent &&
MediaTypeHeaderValue.TryParse(dataContent.MediaType, out var mediaType) &&
mediaType.Equals(s_json))
{
// Deserialize the state JSON directly from UTF-8 bytes
try
{
JsonElement stateElement = (JsonElement)JsonSerializer.Deserialize(
dataContent.Data.Span,
this._jsonSerializerOptions.GetTypeInfo(typeof(JsonElement)))!;
// Remove the DataContent from the message contents
lastMessage.Contents.RemoveAt(i);
// If no contents remain, remove the entire message
if (lastMessage.Contents.Count == 0)
{
messagesList.RemoveAt(messagesList.Count - 1);
}
return stateElement;
}
catch (JsonException ex)
{
throw new InvalidOperationException($"Failed to deserialize state JSON from DataContent: {ex.Message}", ex);
}
}
}
return default;
}
public void Dispose()
{
// No resources to dispose
@@ -316,7 +368,7 @@ public sealed class AGUIChatClient : DelegatingChatClient
}
}
private class ServerFunctionCallContent(FunctionCallContent functionCall) : AIContent
private sealed class ServerFunctionCallContent(FunctionCallContent functionCall) : AIContent
{
public FunctionCallContent FunctionCallContent { get; } = functionCall;
}
@@ -27,4 +27,8 @@ internal static class AGUIEventTypes
public const string ToolCallEnd = "TOOL_CALL_END";
public const string ToolCallResult = "TOOL_CALL_RESULT";
public const string StateSnapshot = "STATE_SNAPSHOT";
public const string StateDelta = "STATE_DELTA";
}
@@ -44,6 +44,8 @@ namespace Microsoft.Agents.AI.AGUI;
[JsonSerializable(typeof(ToolCallArgsEvent))]
[JsonSerializable(typeof(ToolCallEndEvent))]
[JsonSerializable(typeof(ToolCallResultEvent))]
[JsonSerializable(typeof(StateSnapshotEvent))]
[JsonSerializable(typeof(StateDeltaEvent))]
[JsonSerializable(typeof(IDictionary<string, object?>))]
[JsonSerializable(typeof(Dictionary<string, object?>))]
[JsonSerializable(typeof(IDictionary<string, System.Text.Json.JsonElement?>))]
@@ -57,6 +59,6 @@ namespace Microsoft.Agents.AI.AGUI;
[JsonSerializable(typeof(float))]
[JsonSerializable(typeof(bool))]
[JsonSerializable(typeof(decimal))]
internal partial class AGUIJsonSerializerContext : JsonSerializerContext
internal sealed partial class AGUIJsonSerializerContext : JsonSerializerContext
{
}
@@ -46,6 +46,7 @@ internal sealed class BaseEventJsonConverter : JsonConverter<BaseEvent>
AGUIEventTypes.ToolCallArgs => jsonElement.Deserialize(options.GetTypeInfo(typeof(ToolCallArgsEvent))) as ToolCallArgsEvent,
AGUIEventTypes.ToolCallEnd => jsonElement.Deserialize(options.GetTypeInfo(typeof(ToolCallEndEvent))) as ToolCallEndEvent,
AGUIEventTypes.ToolCallResult => jsonElement.Deserialize(options.GetTypeInfo(typeof(ToolCallResultEvent))) as ToolCallResultEvent,
AGUIEventTypes.StateSnapshot => jsonElement.Deserialize(options.GetTypeInfo(typeof(StateSnapshotEvent))) as StateSnapshotEvent,
_ => throw new JsonException($"Unknown BaseEvent type discriminator: '{discriminator}'")
};
@@ -95,8 +96,14 @@ internal sealed class BaseEventJsonConverter : JsonConverter<BaseEvent>
case ToolCallResultEvent toolCallResult:
JsonSerializer.Serialize(writer, toolCallResult, options.GetTypeInfo(typeof(ToolCallResultEvent)));
break;
case StateSnapshotEvent stateSnapshot:
JsonSerializer.Serialize(writer, stateSnapshot, options.GetTypeInfo(typeof(StateSnapshotEvent)));
break;
case StateDeltaEvent stateDelta:
JsonSerializer.Serialize(writer, stateDelta, options.GetTypeInfo(typeof(StateDeltaEvent)));
break;
default:
throw new JsonException($"Unknown BaseEvent type: {value.GetType().Name}");
throw new InvalidOperationException($"Unknown event type: {value.GetType().Name}");
}
}
}
@@ -3,6 +3,7 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Net.Http.Headers;
using System.Runtime.CompilerServices;
using System.Text;
using System.Text.Json;
@@ -18,6 +19,9 @@ namespace Microsoft.Agents.AI.AGUI.Shared;
internal static class ChatResponseUpdateAGUIExtensions
{
private static readonly MediaTypeHeaderValue? s_jsonPatchMediaType = new("application/json-patch+json");
private static readonly MediaTypeHeaderValue? s_json = new("application/json");
public static async IAsyncEnumerable<ChatResponseUpdate> AsChatResponseUpdatesAsync(
this IAsyncEnumerable<BaseEvent> events,
JsonSerializerOptions jsonSerializerOptions,
@@ -70,11 +74,73 @@ internal static class ChatResponseUpdateAGUIExtensions
case ToolCallResultEvent toolCallResult:
yield return toolCallAccumulator.EmitToolCallResult(toolCallResult, jsonSerializerOptions);
break;
// State snapshot events
case StateSnapshotEvent stateSnapshot:
if (stateSnapshot.Snapshot.HasValue)
{
yield return CreateStateSnapshotUpdate(stateSnapshot, conversationId, responseId, jsonSerializerOptions);
}
break;
case StateDeltaEvent stateDelta:
if (stateDelta.Delta.HasValue)
{
yield return CreateStateDeltaUpdate(stateDelta, conversationId, responseId, jsonSerializerOptions);
}
break;
}
}
}
private class TextMessageBuilder()
private static ChatResponseUpdate CreateStateSnapshotUpdate(
StateSnapshotEvent stateSnapshot,
string? conversationId,
string? responseId,
JsonSerializerOptions jsonSerializerOptions)
{
// Serialize JsonElement directly to UTF-8 bytes using AOT-safe overload
byte[] jsonBytes = JsonSerializer.SerializeToUtf8Bytes(
stateSnapshot.Snapshot!.Value,
jsonSerializerOptions.GetTypeInfo(typeof(JsonElement)));
DataContent dataContent = new(jsonBytes, "application/json");
return new ChatResponseUpdate(ChatRole.Assistant, [dataContent])
{
ConversationId = conversationId,
ResponseId = responseId,
CreatedAt = DateTimeOffset.UtcNow,
AdditionalProperties = new AdditionalPropertiesDictionary
{
["is_state_snapshot"] = true
}
};
}
private static ChatResponseUpdate CreateStateDeltaUpdate(
StateDeltaEvent stateDelta,
string? conversationId,
string? responseId,
JsonSerializerOptions jsonSerializerOptions)
{
// Serialize JsonElement directly to UTF-8 bytes using AOT-safe overload
byte[] jsonBytes = JsonSerializer.SerializeToUtf8Bytes(
stateDelta.Delta!.Value,
jsonSerializerOptions.GetTypeInfo(typeof(JsonElement)));
DataContent dataContent = new(jsonBytes, "application/json-patch+json");
return new ChatResponseUpdate(ChatRole.Assistant, [dataContent])
{
ConversationId = conversationId,
ResponseId = responseId,
CreatedAt = DateTimeOffset.UtcNow,
AdditionalProperties = new AdditionalPropertiesDictionary
{
["is_state_delta"] = true
}
};
}
private sealed class TextMessageBuilder()
{
private ChatRole _currentRole;
private string? _currentMessageId;
@@ -154,7 +220,7 @@ internal static class ChatResponseUpdateAGUIExtensions
};
}
private class ToolCallBuilder
private sealed class ToolCallBuilder
{
private string? _conversationId;
private string? _responseId;
@@ -348,6 +414,55 @@ internal static class ChatResponseUpdateAGUIExtensions
Role = AGUIRoles.Tool
};
}
else if (content is DataContent dataContent)
{
if (MediaTypeHeaderValue.TryParse(dataContent.MediaType, out var mediaType) && mediaType.Equals(s_json))
{
// State snapshot event
yield return new StateSnapshotEvent
{
#if NET472 || NETSTANDARD2_0
Snapshot = (JsonElement?)JsonSerializer.Deserialize(
dataContent.Data.ToArray(),
jsonSerializerOptions.GetTypeInfo(typeof(JsonElement)))
#else
Snapshot = (JsonElement?)JsonSerializer.Deserialize(
dataContent.Data.Span,
jsonSerializerOptions.GetTypeInfo(typeof(JsonElement)))
#endif
};
}
else if (mediaType is { } && mediaType.Equals(s_jsonPatchMediaType))
{
// State snapshot patch event must be a valid JSON patch,
// but its not up to us to validate that here.
yield return new StateDeltaEvent
{
#if NET472 || NETSTANDARD2_0
Delta = (JsonElement?)JsonSerializer.Deserialize(
dataContent.Data.ToArray(),
jsonSerializerOptions.GetTypeInfo(typeof(JsonElement)))
#else
Delta = (JsonElement?)JsonSerializer.Deserialize(
dataContent.Data.Span,
jsonSerializerOptions.GetTypeInfo(typeof(JsonElement)))
#endif
};
}
else
{
// Text content event
yield return new TextMessageContentEvent
{
MessageId = chatResponse.MessageId!,
#if NET472 || NETSTANDARD2_0
Delta = Encoding.UTF8.GetString(dataContent.Data.ToArray())
#else
Delta = Encoding.UTF8.GetString(dataContent.Data.Span)
#endif
};
}
}
}
}
}
@@ -0,0 +1,21 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json;
using System.Text.Json.Serialization;
#if ASPNETCORE
namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.Shared;
#else
namespace Microsoft.Agents.AI.AGUI.Shared;
#endif
internal sealed class StateDeltaEvent : BaseEvent
{
public StateDeltaEvent()
{
this.Type = AGUIEventTypes.StateDelta;
}
[JsonPropertyName("delta")]
public JsonElement? Delta { get; set; }
}
@@ -0,0 +1,21 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json;
using System.Text.Json.Serialization;
#if ASPNETCORE
namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.Shared;
#else
namespace Microsoft.Agents.AI.AGUI.Shared;
#endif
internal sealed class StateSnapshotEvent : BaseEvent
{
public StateSnapshotEvent()
{
this.Type = AGUIEventTypes.StateSnapshot;
}
[JsonPropertyName("snapshot")]
public JsonElement? Snapshot { get; set; }
}
@@ -9,23 +9,23 @@ namespace Microsoft.Agents.AI.DevUI;
/// </summary>
public static class DevUIExtensions
{
/// <summary>
/// Adds the necessary services for the DevUI to the application builder.
/// </summary>
public static IHostApplicationBuilder AddDevUI(this IHostApplicationBuilder builder)
{
ArgumentNullException.ThrowIfNull(builder);
builder.Services.AddOpenAIConversations();
builder.Services.AddOpenAIResponses();
return builder;
}
/// <summary>
/// Maps an endpoint that serves the DevUI from the '/devui' path.
/// </summary>
/// <remarks>
/// DevUI requires the OpenAI Responses and Conversations services to be registered with
/// <see cref="MicrosoftAgentAIHostingOpenAIServiceCollectionExtensions.AddOpenAIResponses(IServiceCollection)"/> and
/// <see cref="MicrosoftAgentAIHostingOpenAIServiceCollectionExtensions.AddOpenAIConversations(IServiceCollection)"/>,
/// and the corresponding endpoints to be mapped using
/// <see cref="MicrosoftAgentAIHostingOpenAIEndpointRouteBuilderExtensions.MapOpenAIResponses(IEndpointRouteBuilder)"/> and
/// <see cref="MicrosoftAgentAIHostingOpenAIEndpointRouteBuilderExtensions.MapOpenAIConversations(IEndpointRouteBuilder)"/>.
/// </remarks>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the endpoint to.</param>
/// <returns>A <see cref="IEndpointConventionBuilder"/> that can be used to add authorization or other endpoint configuration.</returns>
/// <seealso cref="MicrosoftAgentAIHostingOpenAIServiceCollectionExtensions.AddOpenAIResponses(IServiceCollection)"/>
/// <seealso cref="MicrosoftAgentAIHostingOpenAIServiceCollectionExtensions.AddOpenAIConversations(IServiceCollection)"/>
/// <seealso cref="MicrosoftAgentAIHostingOpenAIEndpointRouteBuilderExtensions.MapOpenAIResponses(IEndpointRouteBuilder)"/>
/// <seealso cref="MicrosoftAgentAIHostingOpenAIEndpointRouteBuilderExtensions.MapOpenAIConversations(IEndpointRouteBuilder)"/>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="endpoints"/> is null.</exception>
public static IEndpointConventionBuilder MapDevUI(
this IEndpointRouteBuilder endpoints)
@@ -33,8 +33,6 @@ public static class DevUIExtensions
var group = endpoints.MapGroup("");
group.MapDevUI(pattern: "/devui");
group.MapEntities();
group.MapOpenAIConversations();
group.MapOpenAIResponses();
return group;
}
@@ -24,14 +24,16 @@ var builder = WebApplication.CreateBuilder(args);
// Register your agents
builder.AddAIAgent("assistant", "You are a helpful assistant.");
if (builder.Environment.IsDevelopment())
{
// Add DevUI services
builder.AddDevUI();
}
// Register services for OpenAI responses and conversations (also required for DevUI)
builder.Services.AddOpenAIResponses();
builder.Services.AddOpenAIConversations();
var app = builder.Build();
// Map endpoints for OpenAI responses and conversations (also required for DevUI)
app.MapOpenAIResponses();
app.MapOpenAIConversations();
if (builder.Environment.IsDevelopment())
{
// Map DevUI endpoint to /devui
@@ -83,7 +83,16 @@ public static class AIAgentExtensions
{
// A2A SDK assigns the url on its own
// we can help user if they did not set Url explicitly.
agentCard.Url ??= context;
if (string.IsNullOrEmpty(agentCard.Url))
{
var agentCardUrl = context.TrimEnd('/');
if (!context.EndsWith("/v1/card", StringComparison.Ordinal))
{
agentCardUrl += "/v1/card";
}
agentCard.Url = agentCardUrl;
}
return Task.FromResult(agentCard);
};
@@ -44,22 +44,27 @@ public static class AGUIEndpointRouteBuilderExtensions
var jsonSerializerOptions = jsonOptions.Value.SerializerOptions;
var messages = input.Messages.AsChatMessages(jsonSerializerOptions);
var agent = aiAgent;
var clientTools = input.Tools?.AsAITools().ToList();
ChatClientAgentRunOptions? runOptions = null;
List<AITool>? clientTools = input.Tools?.AsAITools().ToList();
if (clientTools?.Count > 0)
// Create run options with AG-UI context in AdditionalProperties
var runOptions = new ChatClientAgentRunOptions
{
runOptions = new ChatClientAgentRunOptions
ChatOptions = new ChatOptions
{
ChatOptions = new ChatOptions
Tools = clientTools,
AdditionalProperties = new AdditionalPropertiesDictionary
{
Tools = clientTools
["ag_ui_state"] = input.State,
["ag_ui_context"] = input.Context?.Select(c => new KeyValuePair<string, string>(c.Description, c.Value)).ToArray(),
["ag_ui_forwarded_properties"] = input.ForwardedProperties,
["ag_ui_thread_id"] = input.ThreadId,
["ag_ui_run_id"] = input.RunId
}
};
}
}
};
var events = agent.RunStreamingAsync(
// Run the agent and convert to AG-UI events
var events = aiAgent.RunStreamingAsync(
messages,
options: runOptions,
cancellationToken: cancellationToken)
@@ -18,7 +18,7 @@ internal abstract record Tool
/// <summary>
/// The type of the tool.
/// </summary>
[JsonPropertyName("type")]
[JsonIgnore]
public abstract string Type { get; }
}
@@ -30,7 +30,7 @@ internal sealed record FunctionTool : Tool
/// <summary>
/// The type of the tool. Always "function".
/// </summary>
[JsonPropertyName("type")]
[JsonIgnore]
public override string Type => "function";
/// <summary>
@@ -88,7 +88,7 @@ internal sealed record CustomTool : Tool
/// <summary>
/// The type of the tool. Always "custom".
/// </summary>
[JsonPropertyName("type")]
[JsonIgnore]
public override string Type => "custom";
/// <summary>
@@ -24,6 +24,10 @@ internal sealed class AIAgentResponseExecutor : IResponseExecutor
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,
@@ -56,7 +56,7 @@ internal static class AgentRunResponseExtensions
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,
@@ -64,7 +64,7 @@ internal static class AgentRunResponseExtensions
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,
@@ -165,7 +165,7 @@ internal static class AgentRunResponseUpdateExtensions
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,
@@ -13,8 +13,9 @@ using Microsoft.Extensions.Logging;
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
{
@@ -37,16 +38,46 @@ internal sealed class HostedAgentResponseExecutor : IResponseExecutor
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,
@@ -57,8 +88,6 @@ internal sealed class HostedAgentResponseExecutor : IResponseExecutor
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())
@@ -66,7 +95,6 @@ internal sealed class HostedAgentResponseExecutor : IResponseExecutor
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))
{
@@ -75,41 +103,20 @@ internal sealed class HostedAgentResponseExecutor : IResponseExecutor
}
/// <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))
string? agentName = request.Agent?.Name;
// 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)
{
throw new InvalidOperationException("No 'agent.name' or 'model' specified in the request.");
agentName = entityId;
}
// 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);
return agentName;
}
}
@@ -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;
/// </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>
@@ -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>
@@ -147,18 +147,27 @@ internal sealed class InMemoryResponsesService : IResponsesService, IDisposable
this._conversationStorage = conversationStorage;
}
public async ValueTask<ResponseError?> ValidateRequestAsync(
CreateResponse request,
CancellationToken cancellationToken = default)
{
if (request.Conversation is not null && !string.IsNullOrEmpty(request.Conversation.Id) &&
!string.IsNullOrEmpty(request.PreviousResponseId))
{
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)
{
ValidateRequest(request);
// Validate agent resolution early for HostedAgentResponseExecutor
if (this._executor is HostedAgentResponseExecutor hostedExecutor)
{
hostedExecutor.ValidateAgent(request);
}
if (request.Stream == true)
{
throw new InvalidOperationException("Cannot create a streaming response using CreateResponseAsync. Use CreateResponseStreamingAsync instead.");
@@ -189,8 +198,6 @@ internal sealed class InMemoryResponsesService : IResponsesService, IDisposable
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 @@ internal sealed class InMemoryResponsesService : IResponsesService, IDisposable
});
}
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 @@ internal sealed class InMemoryResponsesService : IResponsesService, IDisposable
MaxOutputTokens = request.MaxOutputTokens,
MaxToolCalls = request.MaxToolCalls,
Metadata = metadata,
Model = request.Model ?? "default",
Model = request.Model,
Output = [],
ParallelToolCalls = request.ParallelToolCalls ?? true,
PreviousResponseId = request.PreviousResponseId,
@@ -182,7 +182,9 @@ internal sealed class ResponseInputJsonConverter : JsonConverter<ResponseInput>
return messages is not null ? ResponseInput.FromMessages(messages) : null;
}
throw new JsonException($"Unexpected token type for ResponseInput: {reader.TokenType}");
throw new JsonException(
"ResponseInput must be either a string or an array of messages. " +
$"Objects are not supported. Received token type: {reader.TokenType}");
}
/// <inheritdoc/>
@@ -34,6 +34,21 @@ internal sealed class ResponsesHttpHandler
[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 @@ internal sealed class ResponsesHttpHandler
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"
}
});
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("not found") || ex.Message.Contains("does not exist"))
catch (Exception ex)
{
// Return OpenAI-style error for not found errors
return Results.NotFound(new ErrorResponse
{
Error = new ErrorDetails
{
Message = ex.Message,
Type = "invalid_request_error"
}
});
}
catch (InvalidOperationException ex) when (ex.Message.Contains("No 'agent.name' or 'model' specified"))
{
// 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");
}
}