Skip to content

Commit cc8a15f

Browse files
authored
.Net: Bound element nesting depth in XML prompt parsing (#14504)
## Motivation and Context `XmlPromptParser` converts chat prompts such as `<message role="user">...</message>` into `PromptNode` trees that `ChatPromptParser` turns into a `ChatHistory`. Each `PromptNode` stores the serialized inner XML of its element, so the parser re-serializes and decodes the whole remaining subtree at every nesting level. Parse work therefore grows with nesting depth multiplied by prompt size, and the recursive node builder had no depth bound. Supported chat prompt structure is shallow: message elements with optional `text`, `image`, `audio`, and `binary` children, plus encoded or CDATA text. This change bounds element nesting so parsing cost stays proportional to prompt size. ## Description - Add `XmlPromptParser.MaxElementDepth` (internal, `64`, top-level prompt elements count as depth 1, matching the `System.Text.Json` default maximum depth). - After the prompt is loaded and before any `PromptNode` is built, a single non-recursive walk checks element depth. Prompts nested deeper than the limit return `false` from `TryParse`. - A rejected prompt takes the existing invalid-XML path: callers such as `GetChatMessageContentsAsync(string)` send the whole prompt as one user text message, exactly as they already do for malformed XML. - Parse work for accepted prompts is bounded by the depth limit times prompt size, and recursion in `GetPromptNode` is bounded. - No public API change. Prompts within the limit, including content items, CDATA, and encoded content, parse exactly as before. Tests: - `XmlPromptParserTests`: parses two prompts at exactly the maximum depth; rejects one level over and far over the limit; rejects when only a later top-level node exceeds the limit (covers sibling traversal after a max-depth subtree). - `ChatPromptParserTests`: a user message whose content exceeds the limit makes `ChatPromptParser.TryParse` return `false`. ## Validation All local runs use `-c Debug` on .NET SDK 10.0.401. - Build with `--warnaserror`: `SemanticKernel.Abstractions` (`net10.0`, `net8.0`, `netstandard2.0`), `SemanticKernel.UnitTests`, `Extensions.UnitTests`, `PromptTemplates.Liquid.UnitTests`, `Agents.UnitTests`, `IntegrationTests`: 0 warnings, 0 errors. - `dotnet format --verify-no-changes` in `mcr.microsoft.com/dotnet/sdk:10.0` for both changed projects: clean. - `SemanticKernel.UnitTests`: 1614/1614 pass, including the 5 new tests. The new over-limit tests fail without the parser change. - `Extensions.UnitTests` (Handlebars) 90/90, `PromptTemplates.Liquid.UnitTests` 16/16, `Agents.UnitTests` 504/504 executed tests pass, `IntegrationTests` CrossLanguage (offline, stubbed HTTP request shape) 37/37. - Normal chat prompts produce the same `ChatHistory` on `net10.0`, `net8.0`, and .NET Framework 4.8 (`netstandard2.0` asset), and the largest prompts still accepted parse in time proportional to their size. ## Contribution Checklist - [x] The code builds clean without any errors or warnings - [x] The PR follows the [SK Contribution Guidelines](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md) and the [pre-submission formatting script](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md#development-scripts) raises no violations - [x] All unit tests pass, and I have added new tests where possible - [x] I didn't break anyone 😄
1 parent 64005c7 commit cc8a15f

3 files changed

Lines changed: 172 additions & 1 deletion

File tree

‎dotnet/src/SemanticKernel.Abstractions/AI/XmlPromptParser.cs‎

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,26 @@ namespace Microsoft.SemanticKernel;
1414
/// </summary>
1515
internal static class XmlPromptParser
1616
{
17+
/// <summary>
18+
/// Maximum element nesting depth supported in an XML prompt, where top-level prompt elements are at depth 1.
19+
/// </summary>
20+
/// <remarks>
21+
/// Chat prompts only need a shallow structure, such as message elements that contain content elements.
22+
/// Each <see cref="PromptNode"/> stores the serialized content of its whole subtree, so the parsing work
23+
/// grows with the nesting depth multiplied by the prompt size. Limiting the depth keeps that work
24+
/// proportional to the prompt size and also bounds the recursion in <see cref="GetPromptNode"/>.
25+
/// </remarks>
26+
internal const int MaxElementDepth = 64;
27+
1728
/// <summary>
1829
/// Parses text prompt and sets output as collection of <see cref="PromptNode"/> instances.
1930
/// </summary>
2031
/// <param name="prompt">Text prompt to parse.</param>
2132
/// <param name="result">Parsing output as collection of <see cref="PromptNode"/> instances.</param>
22-
/// <returns>Returns true if parsing was successful, otherwise false.</returns>
33+
/// <returns>
34+
/// Returns true if parsing was successful, otherwise false. Prompts whose elements are nested deeper than
35+
/// <see cref="MaxElementDepth"/> are not parsed.
36+
/// </returns>
2337
public static bool TryParse(string prompt, [NotNullWhen(true)] out List<PromptNode>? result)
2438
{
2539
result = null;
@@ -57,6 +71,13 @@ public static bool TryParse(string prompt, [NotNullWhen(true)] out List<PromptNo
5771
return false;
5872
}
5973

74+
// Check the depth before building prompt nodes, because building them serializes every subtree.
75+
// A rejected prompt takes the same path as invalid XML, so callers use it as plain text.
76+
if (ExceedsMaxElementDepth(xmlDocument.DocumentElement!))
77+
{
78+
return false;
79+
}
80+
6081
foreach (XmlNode node in xmlDocument.DocumentElement!.ChildNodes)
6182
{
6283
if (GetPromptNode(node) is { } childPromptNode)
@@ -115,4 +136,48 @@ public static bool TryParse(string prompt, [NotNullWhen(true)] out List<PromptNo
115136

116137
return promptNode;
117138
}
139+
140+
/// <summary>
141+
/// Checks whether any element below <paramref name="root"/> is nested deeper than <see cref="MaxElementDepth"/>.
142+
/// </summary>
143+
/// <param name="root">The wrapper element that contains the top-level prompt elements.</param>
144+
private static bool ExceedsMaxElementDepth(XmlElement root)
145+
{
146+
// Walk the tree without recursion so that a deeply nested document cannot exhaust the stack here.
147+
var depth = 1;
148+
var node = root.FirstChild;
149+
150+
while (node is not null)
151+
{
152+
if (node.NodeType == XmlNodeType.Element)
153+
{
154+
if (depth > MaxElementDepth)
155+
{
156+
return true;
157+
}
158+
159+
if (node.FirstChild is { } firstChild)
160+
{
161+
node = firstChild;
162+
depth++;
163+
continue;
164+
}
165+
}
166+
167+
// Move to the next sibling, climbing back up through parents that have no further siblings.
168+
while (node.NextSibling is null)
169+
{
170+
node = node.ParentNode!;
171+
if (--depth == 0)
172+
{
173+
// Climbing to depth 0 means the walk has returned to the root element.
174+
return false;
175+
}
176+
}
177+
178+
node = node.NextSibling;
179+
}
180+
181+
return false;
182+
}
118183
}

‎dotnet/src/SemanticKernel.UnitTests/Prompt/ChatPromptParserTests.cs‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,32 @@ public void ItReturnsNullChatHistoryWhenPromptIsPlainTextOrInvalid(string prompt
2424
Assert.Null(chatHistory);
2525
}
2626

27+
[Fact]
28+
public void ItReturnsNullChatHistoryWhenMessageContentExceedsMaximumElementDepth()
29+
{
30+
// Arrange
31+
var nestedContent = new StringBuilder();
32+
for (var i = 1; i < XmlPromptParser.MaxElementDepth + 1; i++)
33+
{
34+
nestedContent.Append("<element>");
35+
}
36+
37+
nestedContent.Append("content");
38+
for (var i = 1; i < XmlPromptParser.MaxElementDepth + 1; i++)
39+
{
40+
nestedContent.Append("</element>");
41+
}
42+
43+
var prompt = $"<message role='system'>System message.</message><message role='user'>{nestedContent}</message>";
44+
45+
// Act
46+
var result = ChatPromptParser.TryParse(prompt, out var chatHistory);
47+
48+
// Assert
49+
Assert.False(result);
50+
Assert.Null(chatHistory);
51+
}
52+
2753
[Fact]
2854
public void ItReturnsChatHistoryWithValidRolesWhenPromptIsValid()
2955
{

‎dotnet/src/SemanticKernel.UnitTests/Prompt/XmlPromptParserTests.cs‎

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
using System.Collections.Generic;
44
using System.Linq;
5+
using System.Text;
56
using Microsoft.SemanticKernel;
67
using Xunit;
78

@@ -77,6 +78,85 @@ Test line with tab.
7778
}
7879
}
7980

81+
[Fact]
82+
public void ItParsesPromptAtMaximumElementDepth()
83+
{
84+
// Arrange
85+
var prompt = CreateNestedPrompt(XmlPromptParser.MaxElementDepth) + CreateNestedPrompt(XmlPromptParser.MaxElementDepth);
86+
87+
// Act
88+
var result = XmlPromptParser.TryParse(prompt, out var nodes);
89+
90+
// Assert
91+
Assert.True(result);
92+
Assert.NotNull(nodes);
93+
Assert.Equal(2, nodes.Count);
94+
95+
foreach (var node in nodes)
96+
{
97+
var current = node;
98+
var depth = 1;
99+
while (current.ChildNodes.Count > 0)
100+
{
101+
current = Assert.Single(current.ChildNodes);
102+
depth++;
103+
}
104+
105+
Assert.Equal(XmlPromptParser.MaxElementDepth, depth);
106+
Assert.Equal("content", current.Content);
107+
}
108+
}
109+
110+
[Theory]
111+
[InlineData(1)]
112+
[InlineData(1000)]
113+
public void ItReturnsFalseWhenPromptExceedsMaximumElementDepth(int extraDepth)
114+
{
115+
// Arrange
116+
var prompt = CreateNestedPrompt(XmlPromptParser.MaxElementDepth + extraDepth);
117+
118+
// Act
119+
var result = XmlPromptParser.TryParse(prompt, out var nodes);
120+
121+
// Assert
122+
Assert.False(result);
123+
Assert.Null(nodes);
124+
}
125+
126+
[Fact]
127+
public void ItReturnsFalseWhenLaterPromptNodeExceedsMaximumElementDepth()
128+
{
129+
// Arrange
130+
var prompt = CreateNestedPrompt(XmlPromptParser.MaxElementDepth) + CreateNestedPrompt(XmlPromptParser.MaxElementDepth + 1);
131+
132+
// Act
133+
var result = XmlPromptParser.TryParse(prompt, out var nodes);
134+
135+
// Assert
136+
Assert.False(result);
137+
Assert.Null(nodes);
138+
}
139+
140+
/// <summary>
141+
/// Creates a message element whose total element nesting depth, including the message itself, is <paramref name="depth"/>.
142+
/// </summary>
143+
private static string CreateNestedPrompt(int depth)
144+
{
145+
var builder = new StringBuilder("<message role=\"user\">");
146+
for (var i = 1; i < depth; i++)
147+
{
148+
builder.Append("<element>");
149+
}
150+
151+
builder.Append("content");
152+
for (var i = 1; i < depth; i++)
153+
{
154+
builder.Append("</element>");
155+
}
156+
157+
return builder.Append("</message>").ToString();
158+
}
159+
80160
private void AssertPromptNode(PromptNode expectedNode, PromptNode actualNode)
81161
{
82162
Assert.Equal(expectedNode.TagName, expectedNode.TagName);

0 commit comments

Comments
 (0)