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
35 changes: 35 additions & 0 deletions runtime/csharp/Prompty.Core.Tests/ResilienceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,41 @@ public void ParseArguments_MarkdownFencesNoLanguage_Stripped()
Assert.Equal("NY", result["city"]?.ToString());
}

[Fact]
public void ParseArguments_MarkdownFencesWithSurroundingWhitespace_Stripped()
{
var result = ToolDispatch.ParseArguments(" \n```json \n {\"city\": \"NY\"} \n ``` \n");
Assert.Equal("NY", result["city"]?.ToString());

result = ToolDispatch.ParseArguments("```{\"city\": \"NY\"}```");
Assert.Equal("NY", result["city"]?.ToString());
}

[Fact]
public void ParseArguments_UnclosedFence_FallsThroughToBlockExtraction()
{
var result = ToolDispatch.ParseArguments("```json\n{\"city\": \"NY\"}");
Assert.Equal("NY", result["city"]?.ToString());

Assert.True(ToolDispatch.ParseArguments("```json\n```").ContainsKey("_error"));
Assert.True(ToolDispatch.ParseArguments("``````").ContainsKey("_error"));
}

[Theory]
[InlineData(' ')]
[InlineData('\n')]
public void ParseArguments_UnclosedFenceFollowedByWhitespace_DoesNotBacktrack(char filler)
{
var arguments = "```json" + new string(filler, 20000) + "x";

var started = System.Diagnostics.Stopwatch.StartNew();
var result = ToolDispatch.ParseArguments(arguments);
started.Stop();

Assert.True(result.ContainsKey("_error"));
Assert.True(started.ElapsedMilliseconds < 1000, $"took {started.ElapsedMilliseconds} ms");
}

[Fact]
public void ParseArguments_JsonInProse_Extracted()
{
Expand Down
22 changes: 19 additions & 3 deletions runtime/csharp/Prompty.Core/ToolDispatch.cs
Original file line number Diff line number Diff line change
Expand Up @@ -260,10 +260,9 @@ private static bool ResolveBindings(
if (direct is not null) return direct;

// Strategy 2: Strip markdown code fences
var fenceMatch = Regex.Match(arguments, @"^\s*```(?:json)?\s*\n?([\s\S]*?)\n?\s*```\s*$");
if (fenceMatch.Success)
var stripped = StripCodeFence(arguments);
if (stripped is not null)
{
var stripped = fenceMatch.Groups[1].Value;
var fenced = TryParseJson(stripped);
if (fenced is not null)
{
Expand Down Expand Up @@ -342,6 +341,23 @@ private static bool ResolveBindings(
/// <summary>
/// Try to parse a JSON string into a dictionary. Returns null on failure.
/// </summary>
/// <summary>
/// Returns the text inside a surrounding markdown code fence, or null when the input is not fenced.
/// String operations rather than a regex: the obvious <c>^\s*```(?:json)?\s*\n?([\s\S]*?)\n?\s*```\s*$</c>
/// backtracks cubically on an opening fence that never closes, and tool arguments are model output.
/// </summary>
private static string? StripCodeFence(string arguments)
{
var text = arguments.Trim();
if (text.Length < 6 || !text.StartsWith("```", StringComparison.Ordinal) || !text.EndsWith("```", StringComparison.Ordinal))
return null;

var inner = text[3..^3];
if (inner.StartsWith("json", StringComparison.Ordinal))
inner = inner[4..];
return inner.Trim();
}

private static Dictionary<string, object?>? TryParseJson(string json)
{
try
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
Expand All @@ -33,9 +32,6 @@ public final class ToolDispatch {
/** Longest prefix of unparseable arguments quoted back in an error. */
private static final int ERROR_EXCERPT_CHARS = 200;

private static final Pattern CODE_FENCE =
Pattern.compile("^\\s*```(?:json)?\\s*\\n?(.*?)\\n?\\s*```\\s*$", Pattern.DOTALL);

private static final Pattern TRAILING_COMMA = Pattern.compile(",\\s*([}\\]])");

private ToolDispatch() {}
Expand Down Expand Up @@ -177,14 +173,11 @@ public static Object parseArguments(String raw) {
return direct;
}

Matcher fenced = CODE_FENCE.matcher(text);
if (fenced.matches()) {
String stripped = fenced.group(1);
if (!stripped.equals(text)) {
Object parsed = tryParse(stripped);
if (parsed != NOT_JSON) {
return parsed;
}
String stripped = stripCodeFence(text);
if (stripped != null && !stripped.equals(text)) {
Object parsed = tryParse(stripped);
if (parsed != NOT_JSON) {
return parsed;
}
}

Expand Down Expand Up @@ -220,6 +213,45 @@ private static Object tryParse(String text) {
}
}

/**
* Returns the text inside a surrounding markdown code fence, or {@code null} when the input is
* not fenced.
*
* <p>Plain index arithmetic rather than a regex: the obvious {@code
* ^\s*```(?:json)?\s*\n?(.*?)\n?\s*```\s*$} backtracks cubically on an opening fence that never
* closes, and tool arguments are model output. Whitespace is the ASCII set a regex {@code \s}
* matches, so results are unchanged.
*/
private static String stripCodeFence(String text) {
int start = 0;
int end = text.length();
while (start < end && isSpace(text.charAt(start))) {
start++;
}
while (end > start && isSpace(text.charAt(end - 1))) {
end--;
}
if (end - start < 6 || !text.startsWith("```", start) || !text.startsWith("```", end - 3)) {
return null;
}
int from = start + 3;
int to = end - 3;
if (text.startsWith("json", from) && from + 4 <= to) {
from += 4;
}
while (from < to && isSpace(text.charAt(from))) {
from++;
}
while (to > from && isSpace(text.charAt(to - 1))) {
to--;
}
return text.substring(from, to);
}

private static boolean isSpace(char c) {
return c == ' ' || c == '\t' || c == '\n' || c == 0x0B || c == '\f' || c == '\r';
}

/** Extract the first balanced {@code {...}} block, respecting strings and escapes. */
private static String firstJsonObject(String text) {
int start = text.indexOf('{');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -453,6 +453,33 @@ void plainTextRoundsRecordTheAssistantText() {
assertTrue(result.toolRequests.isEmpty());
}

@Test
void parseArgumentsStripsFencesWithSurroundingWhitespace() {
assertEquals(
Map.of("a", 1), ToolDispatch.parseArguments(" \n```json \n {\"a\": 1} \n ``` \n"));
assertEquals(Map.of("a", 1), ToolDispatch.parseArguments("```{\"a\": 1}```"));
}

@Test
void parseArgumentsFallsThroughWhenTheFenceNeverCloses() {
assertEquals(Map.of("a", 1), ToolDispatch.parseArguments("```json\n{\"a\": 1}"));
assertThrows(IllegalArgumentException.class, () -> ToolDispatch.parseArguments("```json\n```"));
assertThrows(IllegalArgumentException.class, () -> ToolDispatch.parseArguments("``````"));
}

@Test
void parseArgumentsDoesNotBacktrackOnAnUnclosedFenceFollowedByWhitespace() {
for (char filler : new char[] {' ', '\n'}) {
String arguments = "```json" + String.valueOf(filler).repeat(20000) + "x";

long started = System.nanoTime();
assertThrows(IllegalArgumentException.class, () -> ToolDispatch.parseArguments(arguments));
long elapsedMillis = (System.nanoTime() - started) / 1_000_000;

assertTrue(elapsedMillis < 1000, "took " + elapsedMillis + " ms");
}
}

private static Agent agentWithInputs(Map<String, String> kindsByName) {
List<Object> inputs = new ArrayList<>();
kindsByName.forEach((name, kind) -> inputs.add(Map.of("name", name, "kind", kind)));
Expand Down
21 changes: 18 additions & 3 deletions runtime/typescript/packages/core/src/core/tool-dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,21 @@ export function extractFirstJsonBlock(text: string): string | null {
return null;
}

/**
* Return the text inside a surrounding markdown code fence, or null when `raw`
* is not fenced. String operations rather than a regex: the obvious
* `^\s*```(?:json)?\s*\n?([\s\S]*?)\n?\s*```\s*$` backtracks cubically on an
* opening fence that never closes, and tool arguments are model output.
*/
function stripCodeFence(raw: string): string | null {
const text = raw.trim();
if (text.length < 6 || !text.startsWith("```") || !text.endsWith("```")) {
return null;
}
const inner = text.slice(3, -3);
return (inner.startsWith("json") ? inner.slice(4) : inner).trim();
}

/**
* Parse JSON with fallback strategies per spec §9.8.
* Returns parsed value on success, or null if all strategies fail.
Expand All @@ -256,10 +271,10 @@ export function resilientJsonParse(
}

// Strategy 2: Strip markdown code fences
const fenceMatch = raw.match(/^\s*```(?:json)?\s*\n?([\s\S]*?)\n?\s*```\s*$/);
if (fenceMatch) {
const fenced = stripCodeFence(raw);
if (fenced !== null) {
try {
const result = JSON.parse(fenceMatch[1]);
const result = JSON.parse(fenced);
console.warn(
"[prompty] Parsed tool arguments after stripping markdown fences",
);
Expand Down
19 changes: 19 additions & 0 deletions runtime/typescript/packages/core/tests/resilience.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,25 @@ describe("Resilient JSON Parsing (§9.8)", () => {
expect(resilientJsonParse(raw)).toEqual({ temp: 72 });
});

it("tolerates whitespace around the fences", () => {
expect(resilientJsonParse(' \n```json \n {"a": 1} \n ``` \n')).toEqual({ a: 1 });
expect(resilientJsonParse('```{"a": 1}```')).toEqual({ a: 1 });
});

it("falls through when the fence never closes", () => {
expect(resilientJsonParse('```json\n{"a": 1}')).toEqual({ a: 1 });
expect(resilientJsonParse("```json\n```")).toBeNull();
expect(resilientJsonParse("``````")).toBeNull();
});

it("does not backtrack on an unclosed fence followed by whitespace", () => {
for (const filler of [" ", "\n"]) {
const started = performance.now();
expect(resilientJsonParse("```json" + filler.repeat(3000) + "x")).toBeNull();
expect(performance.now() - started).toBeLessThan(1000);
}
});

it("extracts JSON block from prose", () => {
const raw = 'Here is the result: {"city": "NY"} enjoy!';
expect(resilientJsonParse(raw)).toEqual({ city: "NY" });
Expand Down
27 changes: 23 additions & 4 deletions spec/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -2506,10 +2506,9 @@ function resilient_json_parse(raw_arguments) → dict:
return json_parse(raw_arguments)
catch: pass

// Strategy 2: Strip markdown code fences
stripped = regex_replace(raw_arguments,
/^\s*```(?:json)?\s*\n?(.*?)\n?\s*```\s*$/s, "$1")
if stripped != raw_arguments:
// Strategy 2: Strip markdown code fences (string operations, see below)
stripped = strip_code_fence(raw_arguments) // null when not fenced
if stripped is not null:
try:
return json_parse(stripped)
catch: pass
Expand All @@ -2531,6 +2530,26 @@ function resilient_json_parse(raw_arguments) → dict:
return null // caller MUST convert to error tool result
```

`strip_code_fence` is defined with string operations, not a regular expression:

```
function strip_code_fence(raw) → string | null:
t = trim_whitespace(raw)
if length(t) < 6 or not starts_with(t, "```") or not ends_with(t, "```"):
return null // not fenced
inner = t[3 : length(t) - 3]
if starts_with(inner, "json"):
inner = inner[4:] // optional language tag
return trim_whitespace(inner)
```

The tempting single-regex form, `` /^\s*```(?:json)?\s*\n?(.*?)\n?\s*```\s*$/s ``, returns the
same text but backtracks cubically when a fence opens and never closes (for
example an opening fence and `json` followed by 10,000 spaces and no closing
fence), and the arguments string is model output. Implementations MUST NOT
use a backtracking regex for this step. Linear-time engines (Rust's `regex`, Go's
`regexp`) are not affected.

**Requirements:**

- Implementations SHOULD attempt all four strategies in order.
Expand Down
27 changes: 23 additions & 4 deletions web/src/content/docs/specification/agent-loop.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -269,10 +269,9 @@ function resilient_json_parse(raw_arguments) → dict:
return json_parse(raw_arguments)
catch: pass

// Strategy 2: Strip markdown code fences
stripped = regex_replace(raw_arguments,
/^\s*```(?:json)?\s*\n?(.*?)\n?\s*```\s*$/s, "$1")
if stripped != raw_arguments:
// Strategy 2: Strip markdown code fences (string operations, see below)
stripped = strip_code_fence(raw_arguments) // null when not fenced
if stripped is not null:
try:
return json_parse(stripped)
catch: pass
Expand All @@ -294,6 +293,26 @@ function resilient_json_parse(raw_arguments) → dict:
return null // caller MUST convert to error tool result
```

`strip_code_fence` is defined with string operations, not a regular expression:

```
function strip_code_fence(raw) → string | null:
t = trim_whitespace(raw)
if length(t) < 6 or not starts_with(t, "```") or not ends_with(t, "```"):
return null // not fenced
inner = t[3 : length(t) - 3]
if starts_with(inner, "json"):
inner = inner[4:] // optional language tag
return trim_whitespace(inner)
```

The tempting single-regex form, `` /^\s*```(?:json)?\s*\n?(.*?)\n?\s*```\s*$/s ``, returns the
same text but backtracks cubically when a fence opens and never closes (for
example an opening fence and `json` followed by 10,000 spaces and no closing
fence), and the arguments string is model output. Implementations MUST NOT
use a backtracking regex for this step. Linear-time engines (Rust's `regex`, Go's
`regexp`) are not affected.

**Requirements:**

- Implementations SHOULD attempt all four strategies in order.
Expand Down