diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/OutputConverter.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/OutputConverter.cs index 837f32f209..3d373cad3b 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/OutputConverter.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/OutputConverter.cs @@ -7,7 +7,9 @@ using System.Runtime.CompilerServices; using System.Security.Cryptography; using System.Text; +using System.Text.Encodings.Web; using System.Text.Json; +using System.Text.Unicode; using System.Threading; using System.Threading.Tasks; using Azure.AI.AgentServer.Responses; @@ -46,6 +48,9 @@ public static implicit operator OutputConverterItem(ResponseStreamEvent value) = /// internal static class OutputConverter { + // Keep non-ASCII characters as-is while continuing to escape HTML-sensitive characters. + private static readonly JsonSerializerOptions s_jsonOptions = new() { Encoder = JavaScriptEncoder.Create(UnicodeRanges.All) }; + /// /// Converts a stream of into a stream of /// using the SDK builder pattern. @@ -209,7 +214,7 @@ public static async IAsyncEnumerable ConvertUpdatesToItemsA previousMessageId = null; var arguments = functionCall.Arguments is not null - ? JsonSerializer.Serialize(functionCall.Arguments) + ? JsonSerializer.Serialize(functionCall.Arguments, s_jsonOptions) : "{}"; var fcBuilder = stream.AddOutputItemFunctionCall(functionCall.Name, functionCall.CallId); @@ -328,7 +333,7 @@ public static async IAsyncEnumerable ConvertUpdatesToItemsA var wireId = ToolApprovalIdMap.ComputeWireId(approvalRequest.RequestId); var approvalArguments = approvalFunctionCall.Arguments is not null - ? JsonSerializer.Serialize(approvalFunctionCall.Arguments) + ? JsonSerializer.Serialize(approvalFunctionCall.Arguments, s_jsonOptions) : "{}"; ToolApprovalIdMap.Record( @@ -763,13 +768,13 @@ internal static string EncodeFunctionResultAsJsonStringPayload(object? result) string s => s, JsonElement je => je.ValueKind == JsonValueKind.String ? (je.GetString() ?? string.Empty) - : je.GetRawText(), + : JsonSerializer.Serialize(je, s_jsonOptions), JsonDocument jd => jd.RootElement.ValueKind == JsonValueKind.String ? (jd.RootElement.GetString() ?? string.Empty) - : jd.RootElement.GetRawText(), - _ => JsonSerializer.Serialize(result), + : JsonSerializer.Serialize(jd.RootElement, s_jsonOptions), + _ => JsonSerializer.Serialize(result, s_jsonOptions), }; - return JsonSerializer.Serialize(innerText); + return JsonSerializer.Serialize(innerText, s_jsonOptions); } } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/AgentResponseExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/AgentResponseExtensions.cs index 823f0e7fef..1e7ff4a2f9 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/AgentResponseExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/AgentResponseExtensions.cs @@ -200,7 +200,7 @@ public static ChoiceMessageToolCall ToChoiceMessageToolCall(this FunctionCallCon Function = new() { Name = functionCall.Name, - Arguments = JsonSerializer.Serialize(functionCall.Arguments, ChatCompletionsJsonContext.Default.DictionaryStringObject) + Arguments = JsonSerializer.Serialize(functionCall.Arguments, OpenAIHostingJsonUtilities.DefaultOptions.GetTypeInfo(typeof(IDictionary))) } }; } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIHostingJsonUtilities.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIHostingJsonUtilities.cs index 424e34de14..08f5444154 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIHostingJsonUtilities.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/OpenAIHostingJsonUtilities.cs @@ -2,8 +2,10 @@ using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using System.Text.Encodings.Web; using System.Text.Json; using System.Text.Json.Serialization; +using System.Text.Unicode; using Microsoft.Agents.AI.Hosting.OpenAI.Conversations.Models; using Microsoft.Agents.AI.Hosting.OpenAI.Models; using Microsoft.Agents.AI.Hosting.OpenAI.Responses.Models; @@ -31,6 +33,9 @@ private static JsonSerializerOptions CreateDefaultOptions() options.TypeInfoResolverChain.Add(AgentAbstractionsJsonUtilities.DefaultOptions.TypeInfoResolver!); options.TypeInfoResolverChain.Add(OpenAIHostingJsonContext.Default.Options.TypeInfoResolver!); + // Keep non-ASCII characters as-is while continuing to escape HTML-sensitive characters. + options.Encoder = JavaScriptEncoder.Create(UnicodeRanges.All); + options.MakeReadOnly(); return options; } diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/OutputConverterTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/OutputConverterTests.cs index 48982e6a51..29eda0d197 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/OutputConverterTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/OutputConverterTests.cs @@ -443,6 +443,110 @@ public async Task ConvertUpdatesToEventsAsync_FunctionCallWithNullArguments_Emit Assert.IsType(events[^1]); } + [Fact] + public async Task ConvertUpdatesToEventsAsync_FunctionCallNonAsciiArguments_PreservesCharactersAsync() + { + // Arrange + var (stream, _) = CreateTestStream(); + var update = new AgentResponseUpdate + { + Contents = [new FunctionCallContent("call_1", "get_weather", new Dictionary { ["location"] = "Ереван" })] + }; + + // Act + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream)) + { + events.Add(evt); + } + + // Assert + var done = Assert.Single(events.OfType()); + Assert.Equal("""{"location":"Ереван"}""", done.Arguments); + } + + [Fact] + public async Task ConvertUpdatesToEventsAsync_FunctionCallHtmlSensitiveArguments_EscapesCharactersAsync() + { + // Arrange + var (stream, _) = CreateTestStream(); + var update = new AgentResponseUpdate + { + Contents = [new FunctionCallContent("call_1", "search", new Dictionary { ["query"] = "&" })] + }; + + // Act + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream)) + { + events.Add(evt); + } + + // Assert + var done = Assert.Single(events.OfType()); + Assert.Equal("""{"query":"\u003Ctag\u003E\u0026"}""", done.Arguments); + } + + [Fact] + public async Task ConvertUpdatesToEventsAsync_ToolApprovalRequestNonAsciiArguments_PreservesCharactersAsync() + { + // Arrange + var (stream, _) = CreateTestStream(); + var functionCall = new FunctionCallContent("call_1", "get_weather", new Dictionary { ["location"] = "Ереван" }); + var update = new AgentResponseUpdate { Contents = [new ToolApprovalRequestContent("af_request_1", functionCall)] }; + + // Act + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream, new AgentSessionStateBag())) + { + events.Add(evt); + } + + // Assert + var added = Assert.Single(events.OfType()); + var item = Assert.IsType(added.Item); + Assert.Equal("""{"location":"Ереван"}""", item.Arguments); + } + + [Fact] + public void EncodeFunctionResultAsJsonStringPayload_NonAsciiObject_PreservesCharacters() + { + // Arrange + var result = new { city = "Ереван" }; + + // Act + string payload = OutputConverter.EncodeFunctionResultAsJsonStringPayload(result); + + // Assert + Assert.Equal("""{"city":"Ереван"}""", System.Text.Json.JsonSerializer.Deserialize(payload)); + } + + [Fact] + public void EncodeFunctionResultAsJsonStringPayload_JsonElementWithEscapedUnicode_DecodesCharacters() + { + // Arrange + using System.Text.Json.JsonDocument document = System.Text.Json.JsonDocument.Parse("""{"city":"\u0415"}"""); + + // Act + string payload = OutputConverter.EncodeFunctionResultAsJsonStringPayload(document.RootElement); + + // Assert + Assert.Equal("""{"city":"Е"}""", System.Text.Json.JsonSerializer.Deserialize(payload)); + } + + [Fact] + public void EncodeFunctionResultAsJsonStringPayload_JsonDocumentWithEscapedUnicode_DecodesCharacters() + { + // Arrange + using System.Text.Json.JsonDocument document = System.Text.Json.JsonDocument.Parse("""{"city":"\u0415"}"""); + + // Act + string payload = OutputConverter.EncodeFunctionResultAsJsonStringPayload(document); + + // Assert + Assert.Equal("""{"city":"Е"}""", System.Text.Json.JsonSerializer.Deserialize(payload)); + } + // G-04 [Fact] public async Task ConvertUpdatesToEventsAsync_FunctionCallWithEmptyCallId_DoesNotEmitWireItemAsync() diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIChatCompletionsSerializationTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIChatCompletionsSerializationTests.cs index edf11fbf6b..2692ee74ad 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIChatCompletionsSerializationTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIChatCompletionsSerializationTests.cs @@ -6,6 +6,8 @@ using System.Text.Json; using Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Models; using Microsoft.Agents.AI.Hosting.OpenAI.Tests; +using Microsoft.Extensions.AI; +using ChatCompletionsAgentResponseExtensions = Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.AgentResponseExtensions; namespace Microsoft.Agents.AI.Hosting.OpenAI.UnitTests; @@ -385,6 +387,24 @@ public void Deserialize_ResponseRoundTrip_PreservesData() #endregion + #region Function Call Conversion Tests + + [Fact] + public void ToChoiceMessageToolCall_NonAsciiArguments_PreservesCharacters() + { + // Arrange + var functionCall = new FunctionCallContent("call_1", "get_weather", new Dictionary { ["location"] = "Ереван" }); + + // Act + ChoiceMessageToolCall toolCall = ChatCompletionsAgentResponseExtensions.ToChoiceMessageToolCall(functionCall); + + // Assert + Assert.NotNull(toolCall.Function); + Assert.Equal("""{"location":"Ереван"}""", toolCall.Function.Arguments); + } + + #endregion + #region Streaming Chunk Deserialization Tests [Fact] diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesSerializationTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesSerializationTests.cs index 98e3fb4354..ef16215b0e 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesSerializationTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesSerializationTests.cs @@ -6,6 +6,8 @@ using System.Text.Json; using Microsoft.Agents.AI.Hosting.OpenAI.Responses.Models; using Microsoft.Agents.AI.Hosting.OpenAI.Tests; +using Microsoft.Extensions.AI; +using ResponsesAgentResponseExtensions = Microsoft.Agents.AI.Hosting.OpenAI.Responses.AgentResponseExtensions; namespace Microsoft.Agents.AI.Hosting.OpenAI.UnitTests; @@ -766,6 +768,36 @@ public void Deserialize_ResponseRoundTrip_PreservesData() #endregion + #region Function Call Conversion Tests + + [Fact] + public void ToFunctionToolCallItemResource_NonAsciiArguments_PreservesCharacters() + { + // Arrange + var functionCall = new FunctionCallContent("call_1", "get_weather", new Dictionary { ["location"] = "Ереван" }); + + // Act + FunctionToolCallItemResource resource = ResponsesAgentResponseExtensions.ToFunctionToolCallItemResource(functionCall, "fc_1", OpenAIHostingJsonUtilities.DefaultOptions); + + // Assert + Assert.Equal("""{"location":"Ереван"}""", resource.Arguments); + } + + [Fact] + public void ToFunctionToolCallItemResource_HtmlSensitiveArguments_EscapesCharacters() + { + // Arrange + var functionCall = new FunctionCallContent("call_1", "search", new Dictionary { ["query"] = "&" }); + + // Act + FunctionToolCallItemResource resource = ResponsesAgentResponseExtensions.ToFunctionToolCallItemResource(functionCall, "fc_1", OpenAIHostingJsonUtilities.DefaultOptions); + + // Assert + Assert.Equal("""{"query":"\u003Ctag\u003E\u0026"}""", resource.Arguments); + } + + #endregion + #region Streaming Event Deserialization Tests [Fact]