// 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; } }