Skip to content
Merged
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
24 changes: 17 additions & 7 deletions dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/InputConverter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -233,18 +233,28 @@ private static ChatMessage ConvertMcpApprovalRequest(string id, string name, str

/// <summary>
/// Converts an inbound <c>mcp_approval_response</c> wire item to a
/// <see cref="ToolApprovalResponseContent"/>. Looks up the original AF request id
/// via <see cref="ToolApprovalIdMap"/>; falls back to the wire id when the mapping
/// is unavailable. Carries a placeholder <see cref="FunctionCallContent"/> because
/// the original tool-call details are not echoed by clients in the response item.
/// <see cref="ToolApprovalResponseContent"/>. Looks up the original
/// <see cref="FunctionCallContent"/> via <see cref="ToolApprovalIdMap"/> so the
/// reconstructed response carries the original tool name, call id, and arguments.
/// </summary>
/// <exception cref="InvalidOperationException">
/// Thrown when no mapping is recorded for <paramref name="approvalRequestId"/>.
/// Without the mapping the original call cannot be reconstructed, so we fail the request.
/// </exception>
private static ChatMessage ConvertMcpApprovalResponse(string approvalRequestId, bool approve, AgentSessionStateBag? stateBag)
{
var afRequestId = ToolApprovalIdMap.Resolve(stateBag, approvalRequestId);
var placeholderFunctionCall = new FunctionCallContent(afRequestId, "mcp_approval");
var entry = ToolApprovalIdMap.ResolveEntry(stateBag, approvalRequestId)
?? throw new InvalidOperationException(
Comment thread
alliscode marked this conversation as resolved.
$"No approval mapping recorded for wire id '{approvalRequestId}'.");

var functionCall = new FunctionCallContent(
entry.CallId,
entry.Name,
ParseFunctionArgumentsObject(entry.Arguments));

return new ChatMessage(
ChatRole.User,
[new ToolApprovalResponseContent(afRequestId, approve, placeholderFunctionCall)]);
[new ToolApprovalResponseContent(entry.AfRequestId, approve, functionCall)]);
}

[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing tool-call arguments from SDK input.")]
Expand Down
68 changes: 54 additions & 14 deletions dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/OutputConverter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -118,8 +118,13 @@ public static async IAsyncEnumerable<ResponseStreamEvent> ConvertUpdatesToEvents
break;
}

case FunctionCallContent funcCall:
case FunctionCallContent functionCall:
{
if (functionCall.CallId is not { Length: > 0 })
{
break;
}

foreach (var evt in CloseCurrentMessage(currentMessageBuilder, currentTextBuilder, accumulatedText))
{
yield return evt;
Expand All @@ -130,17 +135,15 @@ public static async IAsyncEnumerable<ResponseStreamEvent> ConvertUpdatesToEvents
accumulatedText = null;
previousMessageId = null;

var callId = funcCall.CallId ?? Guid.NewGuid().ToString("N");
var funcBuilder = stream.AddOutputItemFunctionCall(funcCall.Name, callId);
yield return funcBuilder.EmitAdded();

var arguments = funcCall.Arguments is not null
? JsonSerializer.Serialize(funcCall.Arguments)
var arguments = functionCall.Arguments is not null
? JsonSerializer.Serialize(functionCall.Arguments)
: "{}";

yield return funcBuilder.EmitArgumentsDelta(arguments);
yield return funcBuilder.EmitArgumentsDone(arguments);
yield return funcBuilder.EmitDone();
var fcBuilder = stream.AddOutputItemFunctionCall(functionCall.Name, functionCall.CallId);
yield return fcBuilder.EmitAdded();
Comment thread
alliscode marked this conversation as resolved.
yield return fcBuilder.EmitArgumentsDelta(arguments);
yield return fcBuilder.EmitArgumentsDone(arguments);
yield return fcBuilder.EmitDone();
break;
}

Expand Down Expand Up @@ -191,12 +194,19 @@ public static async IAsyncEnumerable<ResponseStreamEvent> ConvertUpdatesToEvents
// wireId↔afRequestId mapping in the session state bag for later lookup
// when the matching `mcp_approval_response` arrives on a subsequent turn.
var wireId = ToolApprovalIdMap.ComputeWireId(approvalRequest.RequestId);
ToolApprovalIdMap.Record(stateBag, wireId, approvalRequest.RequestId);

var approvalArguments = approvalFunctionCall.Arguments is not null
? JsonSerializer.Serialize(approvalFunctionCall.Arguments)
: "{}";

ToolApprovalIdMap.Record(
stateBag,
wireId,
approvalRequest.RequestId,
approvalFunctionCall.CallId,
approvalFunctionCall.Name,
approvalArguments);

var approvalItem = new OutputItemMcpApprovalRequest(
wireId,
"agent_framework",
Expand Down Expand Up @@ -252,10 +262,40 @@ public static async IAsyncEnumerable<ResponseStreamEvent> ConvertUpdatesToEvents
// These would need to be serialized as base64 or URL references.
break;

case FunctionResultContent:
// Function results are internal to the agent's tool-calling loop
// and are not emitted as output items in the response stream.
case FunctionResultContent functionResult:
{
if (functionResult.CallId is not { Length: > 0 })
{
break;
}

foreach (var evt in CloseCurrentMessage(currentMessageBuilder, currentTextBuilder, accumulatedText))
{
yield return evt;
}

currentTextBuilder = null;
currentMessageBuilder = null;
accumulatedText = null;
previousMessageId = null;

var outputText = functionResult.Result switch
{
null => string.Empty,
string s => s,
_ => JsonSerializer.Serialize(functionResult.Result),
};

var itemId = GenerateItemId("fc");
var outputItem = new OutputItemFunctionToolCallOutput(
functionResult.CallId,
BinaryData.FromString(outputText));

var outputBuilder = stream.AddOutputItem<OutputItemFunctionToolCallOutput>(itemId);
yield return outputBuilder.EmitAdded(outputItem);
yield return outputBuilder.EmitDone(outputItem);
break;
}

default:
break;
Expand Down
92 changes: 79 additions & 13 deletions dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ToolApprovalIdMap.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,41 @@
using System.Collections.Generic;
using System.Security.Cryptography;
using System.Text;
using Microsoft.Extensions.AI;

namespace Microsoft.Agents.AI.Foundry.Hosting;

/// <summary>
/// Helper for translating between agent-framework tool-approval request ids and the
/// strict-format wire ids required by the Responses Server SDK <c>mcp_approval_request</c>
/// item type. The mapping is persisted in <see cref="AgentSessionStateBag"/> so an
/// approval request emitted on one HTTP turn can be matched to the response posted
/// back on the next turn.
/// item type, and for preserving the original <see cref="FunctionCallContent"/> across
/// the request/response round trip. The mapping is persisted in
/// <see cref="AgentSessionStateBag"/>.
/// </summary>
internal static class ToolApprovalIdMap
{
/// <summary>
/// State-bag key used to store the wire-id ↔ AF-request-id mapping.
/// State-bag key used to store the wire-id ↔ approval-entry mapping.
/// </summary>
public const string StateBagKey = "Microsoft.Agents.AI.Foundry.Hosting.ToolApprovalIdMap";

/// <summary>
/// Captures the data needed to reconstruct the original
/// <see cref="FunctionCallContent"/> on the inbound (response) side.
/// </summary>
/// <remarks>
/// FICC composes <c>RequestId</c> as <c>"ficc_{CallId}"</c>; <c>CallId</c> is stored
/// independently so the reconstructed function-call id matches the one the model
/// emitted and the backend Conversations API persisted.
/// </remarks>
internal sealed class ApprovalEntry
{
public string AfRequestId { get; set; } = string.Empty;
public string CallId { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
Comment thread
alliscode marked this conversation as resolved.
public string? Arguments { get; set; }
}

/// <summary>
/// SDK item-id format constraints: <c>{prefix}_{50_or_48_chars}</c>. We use the
/// canonical <c>mcpr_</c> prefix and a SHA-256 truncated to 50 hex chars (25 bytes)
Expand All @@ -41,33 +59,81 @@ public static string ComputeWireId(string afRequestId)
}

/// <summary>
/// Records the wire-id → AF-request-id mapping in the supplied state bag.
/// Records the wire-id → approval-entry mapping in the supplied state bag.
/// Arguments are passed as already-serialized JSON to keep this method
/// trim/AOT-friendly (no polymorphic <c>object</c> serialization here).
/// No-op when <paramref name="callId"/> or <paramref name="name"/> is empty —
/// without those fields the entry cannot be used to faithfully reconstruct
/// the original <see cref="FunctionCallContent"/> on the inbound side.
/// </summary>
public static void Record(AgentSessionStateBag? stateBag, string wireId, string afRequestId)
public static void Record(AgentSessionStateBag? stateBag, string wireId, string afRequestId, string? callId, string? name, string? argumentsJson)
Comment thread
alliscode marked this conversation as resolved.
{
if (stateBag is null)
{
return;
}

var map = stateBag.GetValue<Dictionary<string, string>>(StateBagKey)
?? new Dictionary<string, string>(StringComparer.Ordinal);
map[wireId] = afRequestId;
if (string.IsNullOrEmpty(callId) || string.IsNullOrEmpty(name))
{
return;
}

var map = LoadMap(stateBag);
map[wireId] = new ApprovalEntry
{
AfRequestId = afRequestId,
CallId = callId!,
Name = name!,
Arguments = argumentsJson,
};
stateBag.SetValue(StateBagKey, map);
}

/// <summary>
/// Looks up the AF request id for a given wire id. Returns the wire id verbatim
/// when no mapping is present (best-effort fallback that keeps converters total).
/// when no mapping is present.
/// </summary>
public static string Resolve(AgentSessionStateBag? stateBag, string wireId)
{
if (stateBag?.GetValue<Dictionary<string, string>>(StateBagKey) is { } map
&& map.TryGetValue(wireId, out var afRequestId))
if (TryLoadMap(stateBag, out var map)
&& map.TryGetValue(wireId, out var entry))
{
return afRequestId;
return entry.AfRequestId;
}

return wireId;
}

/// <summary>
/// Looks up the full approval entry for a given wire id, or <see langword="null"/>
/// when no mapping is present.
/// </summary>
public static ApprovalEntry? ResolveEntry(AgentSessionStateBag? stateBag, string wireId)
{
if (TryLoadMap(stateBag, out var map)
&& map.TryGetValue(wireId, out var entry))
{
return entry;
}

return null;
}

private static Dictionary<string, ApprovalEntry> LoadMap(AgentSessionStateBag stateBag)
=> TryLoadMap(stateBag, out var map) ? map : new Dictionary<string, ApprovalEntry>(StringComparer.Ordinal);

private static bool TryLoadMap(AgentSessionStateBag? stateBag, out Dictionary<string, ApprovalEntry> map)
{
if (stateBag is null)
{
map = null!;
return false;
}

// Don't swallow JsonException: ConvertMcpApprovalResponse fails fast on a missing entry,
// so an empty map here would just turn a clear deserialization error into a confusing one.
map = stateBag.GetValue<Dictionary<string, ApprovalEntry>>(StateBagKey)
?? new Dictionary<string, ApprovalEntry>(StringComparer.Ordinal);
return true;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -780,25 +780,33 @@ public void ConvertItemsToMessages_McpApprovalRequest_ProducesToolApprovalReques
}

[Fact]
public void ConvertItemsToMessages_McpApprovalResponse_ProducesToolApprovalResponse_FallsBackToWireIdWhenNoMapping()
public void ConvertItemsToMessages_McpApprovalResponse_ThrowsWhenNoMapping()
{
// Without a recorded ApprovalEntry the converter cannot reconstruct the original
// function call faithfully — any placeholder it produced would still fail downstream
// (FICC has no tool to invoke; Azure's stored function_call can't pair with the
// synthetic id). Fail fast with a clear error instead of continuing into a confusing
// HTTP 400 deep inside the agent loop.
var wireId = "mcpr_" + new string('a', 50);
var item = new MCPApprovalResponse(approvalRequestId: wireId, approve: true);

var messages = InputConverter.ConvertItemsToMessages([item]);

var content = Assert.IsType<ToolApprovalResponseContent>(Assert.Single(messages[0].Contents));
Assert.Equal(wireId, content.RequestId);
Assert.True(content.Approved);
var ex = Assert.Throws<InvalidOperationException>(() => InputConverter.ConvertItemsToMessages([item]));
Assert.Contains(wireId, ex.Message);
}
Comment thread
alliscode marked this conversation as resolved.

[Fact]
public void ConvertItemsToMessages_McpApprovalResponse_ResolvesAfRequestIdFromStateBag()
{
const string AfRequestId = "af_request_xyz";
const string AfRequestId = "ficc_call_xyz";
var wireId = ToolApprovalIdMap.ComputeWireId(AfRequestId);
var stateBag = new AgentSessionStateBag();
ToolApprovalIdMap.Record(stateBag, wireId, AfRequestId);
ToolApprovalIdMap.Record(
stateBag,
wireId,
AfRequestId,
"call_xyz",
"issue_refund",
"{\"order_id\":123}");

var item = new MCPApprovalResponse(approvalRequestId: wireId, approve: false);

Expand All @@ -807,6 +815,17 @@ public void ConvertItemsToMessages_McpApprovalResponse_ResolvesAfRequestIdFromSt
var content = Assert.IsType<ToolApprovalResponseContent>(Assert.Single(messages[0].Contents));
Assert.Equal(AfRequestId, content.RequestId);
Assert.False(content.Approved);

// Verify the original FunctionCallContent is reconstructed losslessly:
// - CallId matches the model-issued id (without FICC's "ficc_" prefix), so the
// resulting function_call_output pairs with Azure's stored function_call.
// - Name matches the original tool, so FICC can invoke the right function on resume.
// - Arguments are preserved.
var fcc = Assert.IsType<FunctionCallContent>(content.ToolCall);
Assert.Equal("call_xyz", fcc.CallId);
Assert.Equal("issue_refund", fcc.Name);
Assert.NotNull(fcc.Arguments);
Assert.Equal(123, ((System.Text.Json.JsonElement)fcc.Arguments!["order_id"]!).GetInt32());
}

[Fact]
Expand All @@ -828,10 +847,16 @@ public void ConvertOutputItemsToMessages_McpApprovalRequest_ProducesToolApproval
[Fact]
public void ConvertOutputItemsToMessages_McpApprovalResponse_ProducesToolApprovalResponse()
{
const string AfRequestId = "af_request_history";
const string AfRequestId = "ficc_call_history";
var wireId = ToolApprovalIdMap.ComputeWireId(AfRequestId);
var stateBag = new AgentSessionStateBag();
ToolApprovalIdMap.Record(stateBag, wireId, AfRequestId);
ToolApprovalIdMap.Record(
stateBag,
wireId,
AfRequestId,
"call_history",
"delete_file",
"{\"path\":\"/tmp/x\"}");

var item = new OutputItemMcpApprovalResponseResource(
id: "ar_history_id",
Expand All @@ -843,6 +868,10 @@ public void ConvertOutputItemsToMessages_McpApprovalResponse_ProducesToolApprova
var content = Assert.IsType<ToolApprovalResponseContent>(Assert.Single(messages[0].Contents));
Assert.Equal(AfRequestId, content.RequestId);
Assert.True(content.Approved);

var fcc = Assert.IsType<FunctionCallContent>(content.ToolCall);
Assert.Equal("call_history", fcc.CallId);
Assert.Equal("delete_file", fcc.Name);
}

[Fact]
Expand All @@ -862,6 +891,28 @@ public void ConvertItemsToMessages_McpApprovalRequest_MalformedArguments_Preserv
Assert.Equal("not valid json", fc.Arguments!["_raw"]?.ToString());
}

[Fact]
public void ToolApprovalIdMap_Record_EmptyCallId_IsNoOp()
{
var stateBag = new AgentSessionStateBag();
var wireId = "mcpr_" + new string('d', 50);

ToolApprovalIdMap.Record(stateBag, wireId, "ficc_x", callId: string.Empty, name: "tool", argumentsJson: "{}");

Assert.Null(ToolApprovalIdMap.ResolveEntry(stateBag, wireId));
}

[Fact]
public void ToolApprovalIdMap_Record_EmptyName_IsNoOp()
{
var stateBag = new AgentSessionStateBag();
var wireId = "mcpr_" + new string('e', 50);

ToolApprovalIdMap.Record(stateBag, wireId, "ficc_x", callId: "call_xyz", name: string.Empty, argumentsJson: "{}");

Assert.Null(ToolApprovalIdMap.ResolveEntry(stateBag, wireId));
}

// ── input_file data-URI decoding (TryDecodeTextDataUri) ──

[Fact]
Expand Down
Loading
Loading