// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Security.Cryptography;
using System.Text;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Foundry.Hosting;
///
/// Helper for translating between agent-framework tool-approval request ids and the
/// strict-format wire ids required by the Responses Server SDK mcp_approval_request
/// item type, and for preserving the original across
/// the request/response round trip. The mapping is persisted in
/// .
///
internal static class ToolApprovalIdMap
{
///
/// State-bag key used to store the wire-id ↔ approval-entry mapping.
///
public const string StateBagKey = "Microsoft.Agents.AI.Foundry.Hosting.ToolApprovalIdMap";
///
/// Captures the data needed to reconstruct the original
/// on the inbound (response) side.
///
///
/// FICC composes RequestId as "ficc_{CallId}"; CallId is stored
/// independently so the reconstructed function-call id matches the one the model
/// emitted and the backend Conversations API persisted.
///
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;
public string? Arguments { get; set; }
}
///
/// SDK item-id format constraints: {prefix}_{50_or_48_chars}. We use the
/// canonical mcpr_ prefix and a SHA-256 truncated to 50 hex chars (25 bytes)
/// for deterministic, format-safe wire ids.
///
public static string ComputeWireId(string afRequestId)
{
ArgumentNullException.ThrowIfNull(afRequestId);
#if NET10_0_OR_GREATER
Span hash = stackalloc byte[32];
SHA256.HashData(Encoding.UTF8.GetBytes(afRequestId), hash);
#else
byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(afRequestId));
#endif
// 25 bytes = 50 hex chars (matches SDK body length 50).
return "mcpr_" + Convert.ToHexString(hash).AsSpan(0, 50).ToString();
}
///
/// 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 object serialization here).
/// No-op when or is empty —
/// without those fields the entry cannot be used to faithfully reconstruct
/// the original on the inbound side.
///
public static void Record(AgentSessionStateBag? stateBag, string wireId, string afRequestId, string? callId, string? name, string? argumentsJson)
{
if (stateBag is null)
{
return;
}
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);
}
///
/// Looks up the AF request id for a given wire id. Returns the wire id verbatim
/// when no mapping is present.
///
public static string Resolve(AgentSessionStateBag? stateBag, string wireId)
{
if (TryLoadMap(stateBag, out var map)
&& map.TryGetValue(wireId, out var entry))
{
return entry.AfRequestId;
}
return wireId;
}
///
/// Looks up the full approval entry for a given wire id, or
/// when no mapping is present.
///
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 LoadMap(AgentSessionStateBag stateBag)
=> TryLoadMap(stateBag, out var map) ? map : new Dictionary(StringComparer.Ordinal);
private static bool TryLoadMap(AgentSessionStateBag? stateBag, out Dictionary 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>(StateBagKey)
?? new Dictionary(StringComparer.Ordinal);
return true;
}
}