From 9ab89d6d60a7bda933dd66fab391effdfbdf1dae Mon Sep 17 00:00:00 2001 From: Yufeng He <40085740+he-yufeng@users.noreply.github.com> Date: Fri, 22 May 2026 23:23:11 +0800 Subject: [PATCH] fix: accept function approval response input --- .../OpenAIHostingJsonUtilities.cs | 1 + .../Responses/AIAgentResponseExecutor.cs | 5 +- .../Converters/ItemParamConverter.cs | 1 + .../Responses/HostedAgentResponseExecutor.cs | 5 +- .../Responses/Models/ItemParam.cs | 32 +++ .../Responses/Models/ResponseInput.cs | 248 +++++++++++++++++- .../FunctionApprovalTests.cs | 44 ++++ 7 files changed, 322 insertions(+), 14 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIHostingJsonUtilities.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIHostingJsonUtilities.cs index f77143c583..1cd0e991e0 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIHostingJsonUtilities.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIHostingJsonUtilities.cs @@ -120,6 +120,7 @@ private static JsonSerializerOptions CreateDefaultOptions() [JsonSerializable(typeof(ResponsesDeveloperMessageItemParam))] [JsonSerializable(typeof(FunctionToolCallItemParam))] [JsonSerializable(typeof(FunctionToolCallOutputItemParam))] +[JsonSerializable(typeof(FunctionApprovalResponseItemParam))] [JsonSerializable(typeof(FileSearchToolCallItemParam))] [JsonSerializable(typeof(ComputerToolCallItemParam))] [JsonSerializable(typeof(ComputerToolCallOutputItemParam))] 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 e2e07d00b7..9396597c76 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/AIAgentResponseExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/AIAgentResponseExecutor.cs @@ -60,10 +60,7 @@ public async IAsyncEnumerable ExecuteAsync( messages.AddRange(conversationHistory); } - foreach (var inputMessage in request.Input.GetInputMessages()) - { - messages.Add(inputMessage.ToChatMessage()); - } + messages.AddRange(request.Input.GetChatMessages()); // Use the extension method to convert streaming updates to streaming response events await foreach (var streamingEvent in this._agent.RunStreamingAsync(messages, options: options, cancellationToken: cancellationToken) diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Converters/ItemParamConverter.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Converters/ItemParamConverter.cs index 9e63bcfd9d..7295deeec5 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Converters/ItemParamConverter.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Converters/ItemParamConverter.cs @@ -30,6 +30,7 @@ internal sealed class ItemParamConverter : JsonConverter "message" => doc.Deserialize(OpenAIHostingJsonContext.Default.ResponsesMessageItemParam), "function_call" => doc.Deserialize(OpenAIHostingJsonContext.Default.FunctionToolCallItemParam), "function_call_output" => doc.Deserialize(OpenAIHostingJsonContext.Default.FunctionToolCallOutputItemParam), + "function_approval_response" => doc.Deserialize(OpenAIHostingJsonContext.Default.FunctionApprovalResponseItemParam), "file_search_call" => doc.Deserialize(OpenAIHostingJsonContext.Default.FileSearchToolCallItemParam), "computer_call" => doc.Deserialize(OpenAIHostingJsonContext.Default.ComputerToolCallItemParam), "computer_call_output" => doc.Deserialize(OpenAIHostingJsonContext.Default.ComputerToolCallOutputItemParam), 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 ad98e9e755..5487170e21 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/HostedAgentResponseExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/HostedAgentResponseExecutor.cs @@ -111,10 +111,7 @@ public async IAsyncEnumerable ExecuteAsync( messages.AddRange(conversationHistory); } - foreach (var inputMessage in request.Input.GetInputMessages()) - { - messages.Add(inputMessage.ToChatMessage()); - } + messages.AddRange(request.Input.GetChatMessages()); await foreach (var streamingEvent in agent.RunStreamingAsync(messages, options: options, cancellationToken: cancellationToken) .ToStreamingResponseAsync(request, context, cancellationToken).ConfigureAwait(false)) diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Models/ItemParam.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Models/ItemParam.cs index df8a378df2..92609b6d2b 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Models/ItemParam.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Models/ItemParam.cs @@ -181,6 +181,38 @@ internal sealed class FunctionToolCallOutputItemParam : ItemParam public required string Output { get; init; } } +/// +/// A function approval response item parameter. +/// +internal sealed class FunctionApprovalResponseItemParam : ItemParam +{ + /// + /// The constant item type identifier for function approval response items. + /// + public const string ItemType = "function_approval_response"; + + /// + public override string Type => ItemType; + + /// + /// The ID of the approval request being answered. + /// + [JsonPropertyName("approval_request_id")] + public required string ApprovalRequestId { get; init; } + + /// + /// Whether the request was approved. + /// + [JsonPropertyName("approve")] + public bool Approve { get; init; } + + /// + /// Optional reason for the decision. + /// + [JsonPropertyName("reason")] + public string? Reason { get; init; } +} + /// /// A file search tool call item parameter. /// diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Models/ResponseInput.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Models/ResponseInput.cs index d291b93528..8489d949ee 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Models/ResponseInput.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Models/ResponseInput.cs @@ -5,12 +5,13 @@ using System.Linq; using System.Text.Json; using System.Text.Json.Serialization; +using Microsoft.Agents.AI.Hosting.OpenAI.Responses.Converters; using Microsoft.Extensions.AI; namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses.Models; /// -/// Represents the input to a response request, which can be either a simple string or a list of messages. +/// Represents the input to a response request, which can be a simple string, messages, or typed input items. /// [JsonConverter(typeof(ResponseInputJsonConverter))] internal sealed class ResponseInput : IEquatable @@ -19,12 +20,21 @@ private ResponseInput(string text) { this.Text = text ?? throw new ArgumentNullException(nameof(text)); this.Messages = null; + this.Items = null; } private ResponseInput(List messages) { this.Messages = messages ?? throw new ArgumentNullException(nameof(messages)); this.Text = null; + this.Items = null; + } + + private ResponseInput(List items) + { + this.Items = items ?? throw new ArgumentNullException(nameof(items)); + this.Text = null; + this.Messages = null; } /// @@ -42,6 +52,11 @@ private ResponseInput(List messages) /// public static ResponseInput FromMessages(params InputMessage[] messages) => new(messages.ToList()); + /// + /// Creates a ResponseInput from a list of input items. + /// + public static ResponseInput FromItems(List items) => new(items); + /// /// Implicit conversion from string to ResponseInput. /// @@ -67,6 +82,11 @@ private ResponseInput(List messages) /// public bool IsMessages => this.Messages is not null; + /// + /// Gets whether this input is a list of typed input items. + /// + public bool IsItems => this.Items is not null; + /// /// Gets the text value, or null if this is not a text input. /// @@ -77,6 +97,11 @@ private ResponseInput(List messages) /// public List? Messages { get; } + /// + /// Gets the input item value, or null if this is not an item input. + /// + public List? Items { get; } + /// /// Gets the input as a list of InputMessage objects. /// @@ -92,7 +117,154 @@ public List GetInputMessages() }]; } - return this.Messages ?? []; + if (this.Messages is not null) + { + return this.Messages; + } + + if (this.Items is not null) + { + var messages = new List(); + foreach (ItemParam item in this.Items) + { + if (ToInputMessage(item) is { } message) + { + messages.Add(message); + } + } + + return messages; + } + + return []; + } + + /// + /// Gets the input as a list of ChatMessage objects. + /// + [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1024:Use properties where appropriate", Justification = "Method performs transformation logic")] + public List GetChatMessages() + { + if (this.Text is not null) + { + return [new ChatMessage(ChatRole.User, this.Text)]; + } + + if (this.Messages is not null) + { + return this.Messages.ConvertAll(static message => message.ToChatMessage()); + } + + if (this.Items is not null) + { + var messages = new List(); + foreach (ItemParam item in this.Items) + { + if (ToChatMessage(item) is { } message) + { + messages.Add(message); + } + } + + return messages; + } + + return []; + } + + private static InputMessage? ToInputMessage(ItemParam item) => item switch + { + ResponsesUserMessageItemParam userMessage => new InputMessage { Role = ChatRole.User, Content = userMessage.Content }, + ResponsesAssistantMessageItemParam assistantMessage => new InputMessage { Role = ChatRole.Assistant, Content = assistantMessage.Content }, + ResponsesSystemMessageItemParam systemMessage => new InputMessage { Role = ChatRole.System, Content = systemMessage.Content }, + ResponsesDeveloperMessageItemParam developerMessage => new InputMessage { Role = new ChatRole("developer"), Content = developerMessage.Content }, + _ => null + }; + + private static ChatMessage? ToChatMessage(ItemParam item) => item switch + { + ResponsesUserMessageItemParam userMessage => new ChatMessage(ChatRole.User, ToAIContents(userMessage.Content)), + ResponsesAssistantMessageItemParam assistantMessage => new ChatMessage(ChatRole.Assistant, ToAIContents(assistantMessage.Content)), + ResponsesSystemMessageItemParam systemMessage => new ChatMessage(ChatRole.System, ToAIContents(systemMessage.Content)), + ResponsesDeveloperMessageItemParam developerMessage => new ChatMessage(new ChatRole("developer"), ToAIContents(developerMessage.Content)), + FunctionToolCallItemParam functionCall => new ChatMessage( + ChatRole.Assistant, + [new FunctionCallContent(functionCall.CallId, functionCall.Name, ParseFunctionArgumentsObject(functionCall.Arguments))]), + FunctionToolCallOutputItemParam functionOutput => new ChatMessage( + ChatRole.Tool, + [new FunctionResultContent(functionOutput.CallId, functionOutput.Output)]), + FunctionApprovalResponseItemParam approvalResponse => ToChatMessage(approvalResponse), + _ => null + }; + + private static ChatMessage ToChatMessage(FunctionApprovalResponseItemParam approvalResponse) + { + FunctionCallContent placeholderCall = new( + approvalResponse.ApprovalRequestId, + string.Empty, + arguments: null); + ToolApprovalResponseContent content = new(approvalResponse.ApprovalRequestId, approvalResponse.Approve, placeholderCall) + { + Reason = approvalResponse.Reason + }; + + return new ChatMessage(ChatRole.User, [content]); + } + + private static List ToAIContents(InputMessageContent content) + { + if (content.IsText) + { + return [new TextContent(content.Text)]; + } + + if (content.IsContents) + { + var result = new List(); + foreach (ItemContent itemContent in content.Contents!) + { + if (ItemContentConverter.ToAIContent(itemContent) is { } aiContent) + { + result.Add(aiContent); + } + } + + return result; + } + + return []; + } + + private static Dictionary? ParseFunctionArgumentsObject(string? arguments) + { + if (string.IsNullOrWhiteSpace(arguments)) + { + return null; + } + + try + { + using var doc = JsonDocument.Parse(arguments); + var result = new Dictionary(); + foreach (JsonProperty property in doc.RootElement.EnumerateObject()) + { + result[property.Name] = property.Value.ValueKind switch + { + JsonValueKind.String => property.Value.GetString(), + JsonValueKind.Number => property.Value.GetDouble(), + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.Null => null, + _ => property.Value.GetRawText() + }; + } + + return result; + } + catch (JsonException) + { + return new Dictionary { ["_raw"] = arguments }; + } } /// @@ -120,7 +292,12 @@ public bool Equals(ResponseInput? other) return this.Messages.SequenceEqual(other.Messages); } - // One is text, one is messages - not equal + if (this.Items is not null && other.Items is not null) + { + return this.Items.SequenceEqual(other.Items); + } + + // Different input shapes are not equal. return false; } @@ -140,6 +317,11 @@ public override int GetHashCode() return this.Messages.Count > 0 ? this.Messages[0].GetHashCode() : 0; } + if (this.Items is not null) + { + return this.Items.Count > 0 ? this.Items[0].GetHashCode() : 0; + } + return 0; } @@ -178,12 +360,42 @@ internal sealed class ResponseInputJsonConverter : JsonConverter // Check if it's an array if (reader.TokenType == JsonTokenType.StartArray) { - var messages = JsonSerializer.Deserialize(ref reader, OpenAIHostingJsonContext.Default.ListInputMessage); - return messages is not null ? ResponseInput.FromMessages(messages) : null; + using var doc = JsonDocument.ParseValue(ref reader); + bool hasTypedItems = doc.RootElement.EnumerateArray().Any(static element => + element.ValueKind == JsonValueKind.Object && element.TryGetProperty("type", out _)); + + if (!hasTypedItems) + { + var messages = doc.RootElement.Deserialize(OpenAIHostingJsonContext.Default.ListInputMessage); + return messages is not null ? ResponseInput.FromMessages(messages) : null; + } + + var items = new List(); + foreach (JsonElement element in doc.RootElement.EnumerateArray()) + { + if (element.ValueKind == JsonValueKind.Object && element.TryGetProperty("type", out _)) + { + ItemParam? item = element.Deserialize(OpenAIHostingJsonContext.Default.ItemParam); + if (item is not null) + { + items.Add(item); + } + } + else + { + InputMessage? message = element.Deserialize(OpenAIHostingJsonContext.Default.InputMessage); + if (message is not null) + { + items.Add(ToItemParam(message)); + } + } + } + + return ResponseInput.FromItems(items); } throw new JsonException( - "ResponseInput must be either a string or an array of messages. " + + "ResponseInput must be either a string or an array of messages/input items. " + $"Objects are not supported. Received token type: {reader.TokenType}"); } @@ -198,9 +410,33 @@ public override void Write(Utf8JsonWriter writer, ResponseInput value, JsonSeria { JsonSerializer.Serialize(writer, value.Messages!, OpenAIHostingJsonContext.Default.ListInputMessage); } + else if (value.IsItems) + { + JsonSerializer.Serialize(writer, value.Items!, OpenAIHostingJsonContext.Default.ListItemParam); + } else { throw new JsonException("ResponseInput has no value"); } } + + private static ItemParam ToItemParam(InputMessage message) + { + if (message.Role == ChatRole.User) + { + return new ResponsesUserMessageItemParam { Content = message.Content }; + } + + if (message.Role == ChatRole.Assistant) + { + return new ResponsesAssistantMessageItemParam { Content = message.Content }; + } + + if (message.Role == ChatRole.System) + { + return new ResponsesSystemMessageItemParam { Content = message.Content }; + } + + return new ResponsesDeveloperMessageItemParam { Content = message.Content }; + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/FunctionApprovalTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/FunctionApprovalTests.cs index 683c4c0cb4..3efcfdd686 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/FunctionApprovalTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/FunctionApprovalTests.cs @@ -3,9 +3,11 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Net; using System.Net.Http; using System.Text.Json; using System.Threading.Tasks; +using Microsoft.Agents.AI.Hosting.OpenAI.Responses.Models; using Microsoft.Agents.AI.Hosting.OpenAI.Tests; using Microsoft.Extensions.AI; @@ -175,6 +177,48 @@ public async Task FunctionApprovalRequest_SequenceNumbersAreCorrect_SuccessAsync #region ToolApprovalResponseContent Tests + [Fact] + public async Task FunctionApprovalResponseInput_IsAcceptedAndForwardedAsync() + { + // Arrange + const string AgentName = "approval-response-input-agent"; + const string RequestId = "req-devui-123"; + HttpClient client = await this.CreateTestServerAsync(AgentName, "You are a test agent.", string.Empty, (msg) => + [new TextContent("approval response accepted")]); + + string requestJson = $$""" + { + "model": "gpt-4o-mini", + "input": [ + { + "type": "function_approval_response", + "approval_request_id": "{{RequestId}}", + "approve": true, + "reason": "approved in DevUI" + } + ], + "stream": true + } + """; + + CreateResponse? parsedRequest = JsonSerializer.Deserialize(requestJson, OpenAIHostingJsonContext.Default.CreateResponse); + ToolApprovalResponseContent parsedApproval = parsedRequest!.Input.GetChatMessages() + .Single() + .Contents + .OfType() + .Single(); + + // Act + HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, AgentName, requestJson); + _ = await httpResponse.Content.ReadAsStringAsync(); + + // Assert + Assert.Equal(HttpStatusCode.OK, httpResponse.StatusCode); + Assert.Equal(RequestId, parsedApproval.RequestId); + Assert.True(parsedApproval.Approved); + Assert.Equal("approved in DevUI", parsedApproval.Reason); + } + [Fact] public async Task FunctionApprovalResponse_Approved_GeneratesCorrectEvent_SuccessAsync() {