Skip to content
Open
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 @@ -147,6 +147,7 @@ private static JsonSerializerOptions CreateDefaultOptions()
[JsonSerializable(typeof(ItemContentOutputText))]
[JsonSerializable(typeof(ItemContentOutputAudio))]
[JsonSerializable(typeof(ItemContentRefusal))]
[JsonSerializable(typeof(ItemContentFunctionApprovalResponse))]
[JsonSerializable(typeof(TextConfiguration))]
[JsonSerializable(typeof(ResponseTextFormatConfiguration))]
[JsonSerializable(typeof(ResponseTextFormatConfigurationText))]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ public static async IAsyncEnumerable<StreamingResponseEvent> ToStreamingResponse
// Track active item IDs by executor ID to pair invoked/completed/failed events
Dictionary<string, string> executorItemIds = [];

// Stash MCP tool calls by CallId so the result can build a combined mcp_call item,
// matching the pattern in MEAI's OpenAIResponsesChatClient.
Dictionary<string, McpServerToolCallContent>? pendingMcpCalls = null;

AgentResponseUpdate? previousUpdate = null;
StreamingEventGenerator? generator = null;
while (await updateEnumerator.MoveNextAsync().ConfigureAwait(false))
Expand Down Expand Up @@ -173,6 +177,14 @@ public static async IAsyncEnumerable<StreamingResponseEvent> ToStreamingResponse
continue;
}

// Stash MCP tool calls for later correlation with their results.
// The mcp_call spec type combines call + result in a single item.
if (content is McpServerToolCallContent mcpCall)
{
(pendingMcpCalls ??= [])[mcpCall.CallId] = mcpCall;
continue;
}

// Create a new generator if there is no existing one or the existing one does not support the content.
if (generator?.IsSupported(content) != true)
{
Expand All @@ -196,8 +208,9 @@ public static async IAsyncEnumerable<StreamingResponseEvent> ToStreamingResponse
TextReasoningContent => new TextReasoningContentEventGenerator(context.IdGenerator, seq, outputIndex),
FunctionCallContent => new FunctionCallEventGenerator(context.IdGenerator, seq, outputIndex, context.JsonSerializerOptions),
FunctionResultContent => new FunctionResultEventGenerator(context.IdGenerator, seq, outputIndex),
ToolApprovalRequestContent => new ToolApprovalRequestEventGenerator(context.IdGenerator, seq, outputIndex, context.JsonSerializerOptions),
ToolApprovalResponseContent => new ToolApprovalResponseEventGenerator(context.IdGenerator, seq, outputIndex),
McpServerToolResultContent => new McpCallEventGenerator(context.IdGenerator, seq, outputIndex, context.JsonSerializerOptions, pendingMcpCalls),
ToolApprovalRequestContent => new FunctionApprovalRequestEventGenerator(context.IdGenerator, seq, outputIndex, context.JsonSerializerOptions),
ToolApprovalResponseContent => new FunctionApprovalResponseEventGenerator(context.IdGenerator, seq, outputIndex),
ErrorContent => new ErrorContentEventGenerator(context.IdGenerator, seq, outputIndex),
UriContent uriContent when uriContent.HasTopLevelMediaType("image") => new ImageContentEventGenerator(context.IdGenerator, seq, outputIndex),
DataContent dataContent when dataContent.HasTopLevelMediaType("image") => new ImageContentEventGenerator(context.IdGenerator, seq, outputIndex),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
// Copyright (c) Microsoft. All rights reserved.

using System;
using System.Collections.Generic;
using System.Text.Json;
using Microsoft.Agents.AI.Hosting.OpenAI.Responses.Models;
using Microsoft.Extensions.AI;

Expand Down Expand Up @@ -50,6 +52,9 @@ private static string MediaTypeToAudioFormat(string mediaType) =>
// Error/refusal content
ItemContentRefusal refusal => new ErrorContent(refusal.Refusal),

// Tool approval response (DevUI extension)
ItemContentFunctionApprovalResponse approval => CreateFunctionApprovalResponse(approval),

// Image content
ItemContentInputImage inputImage when !string.IsNullOrEmpty(inputImage.ImageUrl) =>
inputImage.ImageUrl!.StartsWith("data:", StringComparison.OrdinalIgnoreCase)
Expand Down Expand Up @@ -159,4 +164,45 @@ DataContent audioData when audioData.HasTopLevelMediaType("audio") =>

return null;
}

/// <summary>
/// Creates a <see cref="ToolApprovalResponseContent"/> from a DevUI function approval response,
/// extracting the function call ID, name, and arguments from the JSON payload.
/// </summary>
private static ToolApprovalResponseContent CreateFunctionApprovalResponse(ItemContentFunctionApprovalResponse approval)
{
string callId = string.Empty;
string name = string.Empty;
string? serverName = null;
Dictionary<string, object?>? arguments = null;

if (approval.FunctionCall is JsonElement fc && fc.ValueKind == JsonValueKind.Object)
{
if (fc.TryGetProperty("id", out var idProp))
{
callId = idProp.GetString() ?? string.Empty;
}

if (fc.TryGetProperty("name", out var nameProp))
{
name = nameProp.GetString() ?? string.Empty;
}

if (fc.TryGetProperty("server_label", out var serverProp))
{
serverName = serverProp.GetString();
}

if (fc.TryGetProperty("arguments", out var argsProp) && argsProp.ValueKind == JsonValueKind.Object)
{
arguments = ItemResourceConversions.ParseArguments(argsProp.GetRawText());
}
}

ToolCallContent toolCall = serverName is not null
? new McpServerToolCallContent(callId, name, serverName) { Arguments = arguments }
: new FunctionCallContent(callId, name, arguments);
Comment on lines +174 to +204
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

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

When function_call is missing or lacks id/name, this constructs McpServerToolCallContent/FunctionCallContent with empty strings. That can silently create malformed tool calls and lead to confusing downstream failures (e.g., mismatch/correlation failures). It would be safer to enforce required fields: if function_call is absent or id/name are null/empty, throw a JsonException (or otherwise reject/ignore the content item) rather than generating an invalid ToolCallContent.

Suggested change
string callId = string.Empty;
string name = string.Empty;
string? serverName = null;
Dictionary<string, object?>? arguments = null;
if (approval.FunctionCall is JsonElement fc && fc.ValueKind == JsonValueKind.Object)
{
if (fc.TryGetProperty("id", out var idProp))
{
callId = idProp.GetString() ?? string.Empty;
}
if (fc.TryGetProperty("name", out var nameProp))
{
name = nameProp.GetString() ?? string.Empty;
}
if (fc.TryGetProperty("server_label", out var serverProp))
{
serverName = serverProp.GetString();
}
if (fc.TryGetProperty("arguments", out var argsProp) && argsProp.ValueKind == JsonValueKind.Object)
{
arguments = ItemResourceConversions.ParseArguments(argsProp.GetRawText());
}
}
ToolCallContent toolCall = serverName is not null
? new McpServerToolCallContent(callId, name, serverName) { Arguments = arguments }
: new FunctionCallContent(callId, name, arguments);
string? callId = null;
string? name = null;
string? serverName = null;
Dictionary<string, object?>? arguments = null;
if (approval.FunctionCall is not JsonElement fc || fc.ValueKind != JsonValueKind.Object)
{
throw new JsonException("Tool function_call must be a JSON object.");
}
if (fc.TryGetProperty("id", out var idProp))
{
callId = idProp.GetString();
}
if (fc.TryGetProperty("name", out var nameProp))
{
name = nameProp.GetString();
}
if (fc.TryGetProperty("server_label", out var serverProp))
{
serverName = serverProp.GetString();
}
if (fc.TryGetProperty("arguments", out var argsProp) && argsProp.ValueKind == JsonValueKind.Object)
{
arguments = ItemResourceConversions.ParseArguments(argsProp.GetRawText());
}
if (string.IsNullOrWhiteSpace(callId))
{
throw new JsonException("Tool function_call is missing required 'id' property.");
}
if (string.IsNullOrWhiteSpace(name))
{
throw new JsonException("Tool function_call is missing required 'name' property.");
}
ToolCallContent toolCall = serverName is not null
? new McpServerToolCallContent(callId!, name!, serverName) { Arguments = arguments }
: new FunctionCallContent(callId!, name!, arguments);

Copilot uses AI. Check for mistakes.

return new ToolApprovalResponseContent(approval.RequestId, approval.Approved, toolCall);
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.

using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using Microsoft.Agents.AI.Hosting.OpenAI.Responses.Models;
using Microsoft.Extensions.AI;
Expand All @@ -17,11 +18,36 @@ internal static class ItemResourceConversions
/// Converts a sequence of <see cref="ItemResource"/> items to a list of <see cref="ChatMessage"/> objects.
/// Only converts message, function call, and function result items. Other item types are skipped.
/// </summary>
public static List<ChatMessage> ToChatMessages(IEnumerable<ItemResource> items)
/// <param name="items">The stored item resources to convert.</param>
/// <param name="incomingApprovalResponseIds">
/// Optional set of approval request IDs being answered by the current request's input.
/// Used to distinguish pending approvals (about to be answered) from orphaned ones.
/// </param>
public static List<ChatMessage> ToChatMessages(IEnumerable<ItemResource> items, ISet<string>? incomingApprovalResponseIds = null)
{
var messages = new List<ChatMessage>();
var itemsList = items as IList<ItemResource> ?? items.ToList();

// Collect IDs of approval requests that have a matching response stored
// as a user message containing ItemContentFunctionApprovalResponse.
// Orphaned requests (e.g. user refreshed before approving) are skipped
// to avoid sending unapproved approval requests to the AI provider.
var answeredApprovalIds = new HashSet<string>();
foreach (var item in itemsList)
{
if (item is ResponsesUserMessageItemResource userMsg)
{
foreach (var content in userMsg.Content)
{
if (content is ItemContentFunctionApprovalResponse approval)
{
answeredApprovalIds.Add(approval.RequestId);
}
}
}
}

foreach (var item in items)
foreach (var item in itemsList)
{
switch (item)
{
Expand Down Expand Up @@ -56,6 +82,55 @@ public static List<ChatMessage> ToChatMessages(IEnumerable<ItemResource> items)
]));
break;

case MCPApprovalRequestItemResource mcpApproval:
// Skip orphaned approval requests that were never responded to
// (neither in stored history nor in the current incoming request).
if (!answeredApprovalIds.Contains(mcpApproval.Id) &&
!(incomingApprovalResponseIds?.Contains(mcpApproval.Id) ?? false))
{
break;
}

var mcpArgs = ParseArguments(mcpApproval.Arguments);

// The mcp_approval_request spec item has a single Id field. MEAI reuses it as
// both the McpServerToolCallContent.CallId and the ToolApprovalRequestContent.RequestId
// because the spec doesn't provide a separate call ID.
var mcpToolCall = new McpServerToolCallContent(
mcpApproval.Id, mcpApproval.Name ?? string.Empty, mcpApproval.ServerLabel ?? string.Empty)
{
Arguments = mcpArgs
};
messages.Add(new ChatMessage(ChatRole.Assistant,
[
new ToolApprovalRequestContent(mcpApproval.Id, mcpToolCall)
]));
break;

case MCPCallItemResource mcpCall:
var mcpCallArgs = ParseArguments(mcpCall.Arguments);

// MEAI adds both the tool call and result as contents in the same
// assistant message, matching AddMcpToolCallContent in OpenAIResponsesChatClient.
var mcpCallContents = new List<AIContent>
{
new McpServerToolCallContent(mcpCall.Id, mcpCall.Name, mcpCall.ServerLabel)
{
Arguments = mcpCallArgs
},
new McpServerToolResultContent(mcpCall.Id)
{
Outputs =
[
mcpCall.Error is not null
? new ErrorContent(mcpCall.Error)
: new TextContent(mcpCall.Output ?? string.Empty)
],
}
};
messages.Add(new ChatMessage(ChatRole.Assistant, mcpCallContents));
break;

// Skip all other item types (reasoning, executor_action, web_search, etc.)
// They are not relevant for conversation context.
}
Expand All @@ -79,7 +154,7 @@ private static List<AIContent> ConvertContents(List<ItemContent> contents)
return result;
}

private static Dictionary<string, object?>? ParseArguments(string? argumentsJson)
internal static Dictionary<string, object?>? ParseArguments(string? argumentsJson)
{
if (string.IsNullOrEmpty(argumentsJson))
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -435,7 +435,11 @@ private async Task ExecuteResponseAsync(string responseId, ResponseState state,
order: SortOrder.Ascending,
cancellationToken: linkedCts.Token).ConfigureAwait(false);

var history = ItemResourceConversions.ToChatMessages(itemsResult.Data);
// Extract approval response IDs from the current request input so that
// pending approval requests in stored history are not treated as orphaned.
var incomingApprovalResponseIds = GetIncomingApprovalResponseIds(request);

var history = ItemResourceConversions.ToChatMessages(itemsResult.Data, incomingApprovalResponseIds);
if (history.Count > 0)
{
conversationHistory = history;
Expand Down Expand Up @@ -553,6 +557,34 @@ private static List<ItemResource> GetInputItems(string responseId, ResponseState
return itemResources;
}

/// <summary>
/// Extracts approval response request IDs from the current request input.
/// Used to distinguish pending approval requests (awaiting response in this request)
/// from orphaned ones (user refreshed before approving).
/// </summary>
private static HashSet<string>? GetIncomingApprovalResponseIds(CreateResponse request)
{
HashSet<string>? ids = null;
foreach (var inputMessage in request.Input.GetInputMessages())
{
if (inputMessage.Content is not { IsContents: true, Contents: { } contents })
{
continue;
}

foreach (var content in contents)
{
if (content is ItemContentFunctionApprovalResponse approval)
{
ids ??= [];
ids.Add(approval.RequestId);
}
}
}

return ids;
}

public void Dispose()
{
this._cache.Dispose();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -549,19 +549,19 @@ internal sealed class MCPCallItemParam : ItemParam
/// The label of the MCP server running the tool.
/// </summary>
[JsonPropertyName("server_label")]
public string? ServerLabel { get; init; }
public required string ServerLabel { get; init; }

/// <summary>
/// The name of the tool that was run.
/// </summary>
[JsonPropertyName("name")]
public string? Name { get; init; }
public required string Name { get; init; }

/// <summary>
/// A JSON string of the arguments passed to the tool.
/// </summary>
[JsonPropertyName("arguments")]
public string? Arguments { get; init; }
public required string Arguments { get; init; }

/// <summary>
/// The output from the tool call.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,7 @@ internal enum FunctionToolCallOutputItemResourceStatus
[JsonDerivedType(typeof(ItemContentOutputText), "output_text")]
[JsonDerivedType(typeof(ItemContentOutputAudio), "output_audio")]
[JsonDerivedType(typeof(ItemContentRefusal), "refusal")]
[JsonDerivedType(typeof(ItemContentFunctionApprovalResponse), "function_approval_response")]
internal abstract class ItemContent
{
/// <summary>
Expand Down Expand Up @@ -443,6 +444,35 @@ internal sealed class ItemContentRefusal : ItemContent
public required string Refusal { get; init; }
}

/// <summary>
/// A function approval response content item.
/// This is a DevUI extension for human-in-the-loop approval workflows.
/// </summary>
internal sealed class ItemContentFunctionApprovalResponse : ItemContent
{
/// <inheritdoc/>
[JsonIgnore]
public override string Type => "function_approval_response";

/// <summary>
/// The ID of the approval request being responded to.
/// </summary>
[JsonPropertyName("request_id")]
public required string RequestId { get; init; }

/// <summary>
/// Whether the function call was approved.
/// </summary>
[JsonPropertyName("approved")]
public bool Approved { get; init; }

/// <summary>
/// The function call that was approved or rejected.
/// </summary>
[JsonPropertyName("function_call")]
public JsonElement? FunctionCall { get; init; }
}

// Additional ItemResource types from TypeSpec

/// <summary>
Expand Down Expand Up @@ -862,19 +892,19 @@ internal sealed class MCPCallItemResource : ItemResource
/// The label of the MCP server running the tool.
/// </summary>
[JsonPropertyName("server_label")]
public string? ServerLabel { get; init; }
public required string ServerLabel { get; init; }

/// <summary>
/// The name of the tool that was run.
/// </summary>
[JsonPropertyName("name")]
public string? Name { get; init; }
public required string Name { get; init; }

/// <summary>
/// A JSON string of the arguments passed to the tool.
/// </summary>
[JsonPropertyName("arguments")]
public string? Arguments { get; init; }
public required string Arguments { get; init; }

/// <summary>
/// The output from the tool call.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -688,14 +688,21 @@ internal sealed class FunctionCallInfo
public required string Id { get; init; }

/// <summary>
/// Gets or sets the function name.
/// Gets or sets the function call name.
/// </summary>
[JsonPropertyName("name")]
public required string Name { get; init; }

/// <summary>
/// Gets or sets the function arguments.
/// Gets or sets the function call arguments.
/// </summary>
[JsonPropertyName("arguments")]
public required JsonElement Arguments { get; init; }

/// <summary>
/// Gets or sets the MCP server label. Present only for MCP tool calls.
/// </summary>
[JsonPropertyName("server_label")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? ServerLabel { get; init; }
}
Loading
Loading