? TryParseJson(string json)
{
try
diff --git a/runtime/java/prompty/src/main/java/com/microsoft/prompty/ToolDispatch.java b/runtime/java/prompty/src/main/java/com/microsoft/prompty/ToolDispatch.java
index 8f8adf90a..2134e92c9 100644
--- a/runtime/java/prompty/src/main/java/com/microsoft/prompty/ToolDispatch.java
+++ b/runtime/java/prompty/src/main/java/com/microsoft/prompty/ToolDispatch.java
@@ -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;
/**
@@ -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() {}
@@ -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;
}
}
@@ -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.
+ *
+ * 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('{');
diff --git a/runtime/java/prompty/src/test/java/com/microsoft/prompty/RuntimeTest.java b/runtime/java/prompty/src/test/java/com/microsoft/prompty/RuntimeTest.java
index 405bd3bed..a74663289 100644
--- a/runtime/java/prompty/src/test/java/com/microsoft/prompty/RuntimeTest.java
+++ b/runtime/java/prompty/src/test/java/com/microsoft/prompty/RuntimeTest.java
@@ -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 kindsByName) {
List