Skip to content
Open
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
207 changes: 207 additions & 0 deletions dotnet/src/Microsoft.Agents.AI/JsonFixer.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Provides utility methods for fixing common JSON malformations
/// that can arise when consuming JSON output from LLMs.
/// </summary>
internal static class JsonFixer
{
/// <summary>
/// Attempts to fix common JSON malformations in the provided text.
/// </summary>
/// <param name="text">The raw text potentially containing JSON.</param>
/// <param name="fixedText">The repaired JSON text, or the original if no fix was needed.</param>
/// <returns><see langword="true"/> if a fix was applied; <see langword="false"/> if the text was already valid or no fix was possible.</returns>
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;
}

/// <summary>
/// Removes markdown code fences (e.g. ```json ... ```) from the text.
/// </summary>
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);
Comment on lines +63 to +64
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;
}

/// <summary>
/// Removes trailing commas before '}', ']', or at the end of the string.
/// </summary>
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*$", "");
Comment on lines +86 to +90

return text != original;
}

/// <summary>
/// Attempts to complete a truncated JSON payload by adding missing closing brackets,
/// braces, and quotes.
/// </summary>
public static bool TryFixTruncatedJson(ref string text)
{
string original = text;
var stack = new Stack<char>();
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;
}

/// <summary>
/// Detects and un-stringifies nested JSON objects that have been embedded
/// as escaped string values (e.g. <c>"arguments": "{\"key\": \"value\"}"</c>
/// becomes <c>"arguments": {"key": "value"}</c>).
/// </summary>
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);
Comment on lines +188 to +189

// 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;
}
}
157 changes: 157 additions & 0 deletions dotnet/tests/Microsoft.Agents.AI.UnitTests/JsonFixerTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
// Copyright (c) Microsoft. All rights reserved.

using Xunit;

namespace Microsoft.Agents.AI.UnitTests;

/// <summary>
/// Tests for <see cref="JsonFixer"/>.
/// </summary>
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);
}
}