diff --git a/dotnet/src/Microsoft.Agents.AI/JsonFixer.cs b/dotnet/src/Microsoft.Agents.AI/JsonFixer.cs new file mode 100644 index 0000000000..dc7804209b --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/JsonFixer.cs @@ -0,0 +1,207 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; +using System.Text; +using System.Text.RegularExpressions; + +namespace Microsoft.Agents.AI; + +/// +/// Provides utility methods for fixing common JSON malformations +/// that can arise when consuming JSON output from LLMs. +/// +internal static class JsonFixer +{ + /// + /// Attempts to fix common JSON malformations in the provided text. + /// + /// The raw text potentially containing JSON. + /// The repaired JSON text, or the original if no fix was needed. + /// if a fix was applied; if the text was already valid or no fix was possible. + public static bool TryFix([NotNullWhen(true)] string? text, out string? fixedText) + { + if (string.IsNullOrEmpty(text)) + { + fixedText = null; + return false; + } + + string result = text; + + bool changed = TryStripMarkdownFences(ref result) + | TryFixTrailingCommas(ref result) + | TryFixTruncatedJson(ref result) + | TryUnstringifyNestedJson(ref result); + + fixedText = changed ? result : null; + return changed; + } + + /// + /// Removes markdown code fences (e.g. ```json ... ```) from the text. + /// + public static bool TryStripMarkdownFences(ref string text) + { + const string FenceMarker = "```"; + int start = text.IndexOf(FenceMarker, StringComparison.Ordinal); + if (start < 0) + { + return false; + } + + // Find the end of the fence line (the newline after the opening fence) + int fenceEnd = text.IndexOf('\n', start); + if (fenceEnd < 0) + { + // ``` at start but no newline — treat rest as code + text = text[(start + 3)..].Trim(); + return true; + } + + int contentStart = fenceEnd + 1; + + // Find closing fence + int closeFence = text.LastIndexOf(FenceMarker, StringComparison.Ordinal); + if (closeFence >= contentStart) + { + // Extract content between fences + text = text[contentStart..closeFence].Trim(); + } + else + { + // No closing fence — treat rest as code + text = text[contentStart..].Trim(); + } + + return true; + } + + /// + /// Removes trailing commas before '}', ']', or at the end of the string. + /// + public static bool TryFixTrailingCommas(ref string text) + { + string original = text; + + // Remove trailing comma before closing brace/bracket + text = Regex.Replace(text, @",(\s*[}\]])", "$1"); + + // Remove trailing comma at end of string (truncated after comma) + text = Regex.Replace(text, @",\s*$", ""); + + return text != original; + } + + /// + /// Attempts to complete a truncated JSON payload by adding missing closing brackets, + /// braces, and quotes. + /// + public static bool TryFixTruncatedJson(ref string text) + { + string original = text; + var stack = new Stack(); + bool inString = false; + bool escaped = false; + + foreach (char c in text) + { + if (inString) + { + if (escaped) + { + escaped = false; + } + else if (c == '\\') + { + escaped = true; + } + else if (c == '"') + { + inString = false; + } + } + else + { + switch (c) + { + case '{': + case '[': + stack.Push(c); + break; + case '}': + if (stack.Count > 0 && stack.Peek() == '{') + { + stack.Pop(); + } + break; + case ']': + if (stack.Count > 0 && stack.Peek() == '[') + { + stack.Pop(); + } + break; + case '"': + inString = true; + break; + } + } + } + + // Close any unclosed string + if (inString) + { + text += '"'; + } + + // Close any unclosed brackets/braces + while (stack.Count > 0) + { + text += stack.Pop() switch + { + '{' => '}', + '[' => ']', + _ => string.Empty + }; + } + + return text != original; + } + + /// + /// Detects and un-stringifies nested JSON objects that have been embedded + /// as escaped string values (e.g. "arguments": "{\"key\": \"value\"}" + /// becomes "arguments": {"key": "value"}). + /// + public static bool TryUnstringifyNestedJson(ref string text) + { + string original = text; + + // Match pattern: "propertyName": "{...}" or "propertyName": "{\\...}" + text = Regex.Replace( + text, + @"\""(\w+)\\"":\s*\""(\{.*?\})\""", + m => + { + string propertyName = m.Groups[1].Value; + string potentialJson = m.Groups[2].Value; + + // Unescape the string + potentialJson = Regex.Unescape(potentialJson); + + // Check if it's valid JSON + try + { + System.Text.Json.JsonDocument.Parse(potentialJson); + // It's valid JSON, so use it directly + return $"\"{propertyName}\": {potentialJson}"; + } + catch + { + // Not valid JSON, keep original + return m.Value; + } + }); + + return text != original; + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/JsonFixerTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/JsonFixerTests.cs new file mode 100644 index 0000000000..ecf2b4bd9d --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/JsonFixerTests.cs @@ -0,0 +1,157 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Xunit; + +namespace Microsoft.Agents.AI.UnitTests; + +/// +/// Tests for . +/// +public class JsonFixerTests +{ + // ---------- Markdown Fence Stripping ---------- + + [Fact] + public void TryStripMarkdownFences_NoFence_ReturnsFalse() + { + string text = @"{""key"": ""value""}"; + string original = text; + bool result = JsonFixer.TryStripMarkdownFences(ref text); + Assert.False(result); + Assert.Equal(original, text); + } + + [Fact] + public void TryStripMarkdownFences_WithFence_RemovesFence() + { + string text = "```json\n{\"key\": \"value\"}\n```"; + string expected = @"{""key"": ""value""}"; + bool result = JsonFixer.TryStripMarkdownFences(ref text); + Assert.True(result); + Assert.Equal(expected, text); + } + + [Fact] + public void TryStripMarkdownFences_NoClosingFence_StripsOpeningFence() + { + string text = "```json\n{\"key\": \"value\"}"; + string expected = @"{""key"": ""value""}"; + bool result = JsonFixer.TryStripMarkdownFences(ref text); + Assert.True(result); + Assert.Equal(expected, text); + } + + // ---------- Trailing Comma Fixing ---------- + + [Fact] + public void TryFixTrailingCommas_NoTrailingComma_ReturnsFalse() + { + string text = @"{""a"": 1, ""b"": 2}"; + string original = text; + bool result = JsonFixer.TryFixTrailingCommas(ref text); + Assert.False(result); + Assert.Equal(original, text); + } + + [Fact] + public void TryFixTrailingCommas_BeforeClosingBrace_RemovesComma() + { + string text = @"{""a"": 1,}"; + string expected = @"{""a"": 1}"; + bool result = JsonFixer.TryFixTrailingCommas(ref text); + Assert.True(result); + Assert.Equal(expected, text); + } + + [Fact] + public void TryFixTrailingCommas_BeforeClosingBracket_RemovesComma() + { + string text = @"[1, 2,]"; + string expected = @"[1, 2]"; + bool result = JsonFixer.TryFixTrailingCommas(ref text); + Assert.True(result); + Assert.Equal(expected, text); + } + + // ---------- Truncated JSON Fixing ---------- + + [Fact] + public void TryFixTruncatedJson_CompleteJson_ReturnsFalse() + { + string text = @"{""a"": 1, ""b"": [1, 2, 3]}"; + string original = text; + bool result = JsonFixer.TryFixTruncatedJson(ref text); + Assert.False(result); + Assert.Equal(original, text); + } + + [Fact] + public void TryFixTruncatedJson_MissingClosingBrace_AddsIt() + { + string text = @"{""a"": 1"; + string expected = @"{""a"": 1}"; + bool result = JsonFixer.TryFixTruncatedJson(ref text); + Assert.True(result); + Assert.Equal(expected, text); + } + + [Fact] + public void TryFixTruncatedJson_MissingBracketsAndBraces_AddsThem() + { + string text = @"{""a"": [1, 2"; + string expected = @"{""a"": [1, 2]}"; + bool result = JsonFixer.TryFixTruncatedJson(ref text); + Assert.True(result); + Assert.Equal(expected, text); + } + + [Fact] + public void TryFixTruncatedJson_UnclosedString_ClosesIt() + { + string text = @"{""a"": ""hello"; + string expected = @"{""a"": ""hello""}"; + bool result = JsonFixer.TryFixTruncatedJson(ref text); + Assert.True(result); + Assert.Equal(expected, text); + } + + // ---------- Combined TryFix ---------- + + [Fact] + public void TryFix_MarkdownFenceWithCommas_FixesBoth() + { + string text = "```json\n{\"a\": 1,}\n```"; + string expected = @"{""a"": 1}"; + bool result = JsonFixer.TryFix(text, out string? fixedText); + Assert.True(result); + Assert.NotNull(fixedText); + Assert.Equal(expected, fixedText); + } + + [Fact] + public void TryFix_TruncatedWithFence_FixesBoth() + { + string text = "```json\n{\"a\": [1, 2"; + string expected = @"{""a"": [1, 2]}"; + bool result = JsonFixer.TryFix(text, out string? fixedText); + Assert.True(result); + Assert.NotNull(fixedText); + Assert.Equal(expected, fixedText); + } + + [Fact] + public void TryFix_NullText_ReturnsFalse() + { + bool result = JsonFixer.TryFix(null, out string? fixedText); + Assert.False(result); + Assert.Null(fixedText); + } + + [Fact] + public void TryFix_EmptyText_ReturnsFalse() + { + bool result = JsonFixer.TryFix(string.Empty, out string? fixedText); + Assert.False(result); + Assert.Null(fixedText); + } +}