feat: add LLM response repair for malformed JSON tool calls

This PR adds an experimental feature to automatically repair malformed JSON
responses from LLMs that struggle with strict output formats.

Changes:
- Add llmResponseRepair experimental setting
- Create JSON repair utility (repair-json.ts) with support for:
  - Trailing commas
  - Single quotes to double quotes conversion
  - Unquoted keys
  - Missing closing brackets/braces
  - Prefixed text stripping
  - Markdown code block extraction
- Integrate repair into NativeToolCallParser as fallback when JSON.parse fails
- Add comprehensive test suite for the repair utility

Closes #10481
This commit is contained in:
Roo Code 2026-01-06 13:31:16 +00:00
parent 861139ca24
commit aff2ed2081
7 changed files with 736 additions and 2 deletions

View file

@ -14,6 +14,7 @@ export const experimentIds = [
"runSlashCommand",
"multipleNativeToolCalls",
"customTools",
"llmResponseRepair",
] as const
export const experimentIdsSchema = z.enum(experimentIds)
@ -32,6 +33,7 @@ export const experimentsSchema = z.object({
runSlashCommand: z.boolean().optional(),
multipleNativeToolCalls: z.boolean().optional(),
customTools: z.boolean().optional(),
llmResponseRepair: z.boolean().optional(),
})
export type Experiments = z.infer<typeof experimentsSchema>

View file

@ -17,6 +17,7 @@ import type {
ApiStreamToolCallEndChunk,
} from "../../api/transform/stream"
import { MCP_TOOL_PREFIX, MCP_TOOL_SEPARATOR, parseMcpToolName } from "../../utils/mcp-name"
import { repairToolCallJson } from "../../utils/repair-json"
/**
* Helper type to extract properly typed native arguments for a given tool.
@ -51,6 +52,28 @@ export type ToolCallStreamEvent = ApiStreamToolCallStartChunk | ApiStreamToolCal
* provider-level raw chunks into start/delta/end events.
*/
export class NativeToolCallParser {
/**
* Configuration for LLM response repair feature.
* When enabled, attempts to repair malformed JSON from LLM responses.
* @see Issue #10481
*/
private static repairEnabled = false
/**
* Enable or disable the LLM response repair feature.
* This should be called when the experimental setting changes.
*/
public static setRepairEnabled(enabled: boolean): void {
this.repairEnabled = enabled
}
/**
* Check if the repair feature is enabled.
*/
public static isRepairEnabled(): boolean {
return this.repairEnabled
}
// Streaming state management for argument accumulation (keyed by tool call id)
// Note: name is string to accommodate dynamic MCP tools (mcp_serverName_toolName)
private static streamingToolCalls = new Map<
@ -593,7 +616,39 @@ export class NativeToolCallParser {
try {
// Parse the arguments JSON string
const args = toolCall.arguments === "" ? {} : JSON.parse(toolCall.arguments)
let args: Record<string, any>
let wasRepaired = false
if (toolCall.arguments === "") {
args = {}
} else {
try {
args = JSON.parse(toolCall.arguments)
} catch (parseError) {
// If repair is enabled, attempt to fix malformed JSON
if (this.repairEnabled) {
const repairResult = repairToolCallJson(toolCall.arguments)
if (repairResult.parsed !== undefined) {
args = repairResult.parsed
wasRepaired = true
console.log(
`[NativeToolCallParser] Successfully repaired malformed JSON for tool '${resolvedName}'`,
)
} else {
// Repair failed, re-throw original error
throw parseError
}
} else {
// Repair not enabled, re-throw original error
throw parseError
}
}
}
// Log repair for debugging (can be removed in production)
if (wasRepaired) {
console.log(`[NativeToolCallParser] Original JSON: ${toolCall.arguments.substring(0, 200)}...`)
}
// Build legacy params object for backward compatibility with XML protocol and UI.
// Native execution path uses nativeArgs instead, which has proper typing.
@ -863,7 +918,29 @@ export class NativeToolCallParser {
public static parseDynamicMcpTool(toolCall: { id: string; name: string; arguments: string }): McpToolUse | null {
try {
// Parse the arguments - these are the actual tool arguments passed directly
const args = JSON.parse(toolCall.arguments || "{}")
let args: Record<string, any>
const argsString = toolCall.arguments || "{}"
try {
args = JSON.parse(argsString)
} catch (parseError) {
// If repair is enabled, attempt to fix malformed JSON
if (this.repairEnabled) {
const repairResult = repairToolCallJson(argsString)
if (repairResult.parsed !== undefined) {
args = repairResult.parsed
console.log(
`[NativeToolCallParser] Successfully repaired malformed JSON for MCP tool '${toolCall.name}'`,
)
} else {
// Repair failed, re-throw original error
throw parseError
}
} else {
// Repair not enabled, re-throw original error
throw parseError
}
}
// Extract server_name and tool_name from the tool name itself
// Format: mcp--serverName--toolName (using -- separator)

View file

@ -33,6 +33,7 @@ describe("experiments", () => {
runSlashCommand: false,
multipleNativeToolCalls: false,
customTools: false,
llmResponseRepair: false,
}
expect(Experiments.isEnabled(experiments, EXPERIMENT_IDS.POWER_STEERING)).toBe(false)
})
@ -46,6 +47,7 @@ describe("experiments", () => {
runSlashCommand: false,
multipleNativeToolCalls: false,
customTools: false,
llmResponseRepair: false,
}
expect(Experiments.isEnabled(experiments, EXPERIMENT_IDS.POWER_STEERING)).toBe(true)
})
@ -59,6 +61,7 @@ describe("experiments", () => {
runSlashCommand: false,
multipleNativeToolCalls: false,
customTools: false,
llmResponseRepair: false,
}
expect(Experiments.isEnabled(experiments, EXPERIMENT_IDS.POWER_STEERING)).toBe(false)
})

View file

@ -8,6 +8,7 @@ export const EXPERIMENT_IDS = {
RUN_SLASH_COMMAND: "runSlashCommand",
MULTIPLE_NATIVE_TOOL_CALLS: "multipleNativeToolCalls",
CUSTOM_TOOLS: "customTools",
LLM_RESPONSE_REPAIR: "llmResponseRepair",
} as const satisfies Record<string, ExperimentId>
type _AssertExperimentIds = AssertEqual<Equals<ExperimentId, Values<typeof EXPERIMENT_IDS>>>
@ -26,6 +27,7 @@ export const experimentConfigsMap: Record<ExperimentKey, ExperimentConfig> = {
RUN_SLASH_COMMAND: { enabled: false },
MULTIPLE_NATIVE_TOOL_CALLS: { enabled: false },
CUSTOM_TOOLS: { enabled: false },
LLM_RESPONSE_REPAIR: { enabled: false },
}
export const experimentDefault = Object.fromEntries(

View file

@ -0,0 +1,308 @@
/**
* Tests for the JSON repair utility.
* @see Issue #10481 - Use of BAML for parsing llm output to correct malformed responses
*/
import { repairJson, repairToolCallJson } from "../repair-json"
describe("repair-json", () => {
describe("repairJson", () => {
describe("valid JSON", () => {
it("should return original JSON without modification for valid input", () => {
const validJson = '{"key": "value", "number": 42}'
const result = repairJson(validJson)
expect(result.repaired).toBe(false)
expect(result.json).toBe(validJson)
expect(result.parsed).toEqual({ key: "value", number: 42 })
expect(result.error).toBeUndefined()
})
it("should handle valid arrays", () => {
const validArray = '[1, 2, 3, "test"]'
const result = repairJson(validArray)
expect(result.repaired).toBe(false)
expect(result.parsed).toEqual([1, 2, 3, "test"])
})
it("should handle empty objects and arrays", () => {
expect(repairJson("{}").parsed).toEqual({})
expect(repairJson("[]").parsed).toEqual([])
})
it("should handle nested structures", () => {
const nested = '{"outer": {"inner": [1, 2, {"deep": true}]}}'
const result = repairJson(nested)
expect(result.repaired).toBe(false)
expect(result.parsed).toEqual({
outer: { inner: [1, 2, { deep: true }] },
})
})
})
describe("trailing commas", () => {
it("should remove trailing comma in object", () => {
const input = '{"key": "value",}'
const result = repairJson(input)
expect(result.repaired).toBe(true)
expect(result.parsed).toEqual({ key: "value" })
})
it("should remove trailing comma in array", () => {
const input = "[1, 2, 3,]"
const result = repairJson(input)
expect(result.repaired).toBe(true)
expect(result.parsed).toEqual([1, 2, 3])
})
it("should remove multiple trailing commas", () => {
const input = '{"a": [1, 2,], "b": 3,}'
const result = repairJson(input)
expect(result.repaired).toBe(true)
expect(result.parsed).toEqual({ a: [1, 2], b: 3 })
})
})
describe("single quotes", () => {
it("should convert single quotes to double quotes", () => {
const input = "{'key': 'value'}"
const result = repairJson(input)
expect(result.repaired).toBe(true)
expect(result.parsed).toEqual({ key: "value" })
})
it("should handle mixed quotes", () => {
const input = "{\"key\": 'value'}"
const result = repairJson(input)
expect(result.repaired).toBe(true)
expect(result.parsed).toEqual({ key: "value" })
})
it("should escape double quotes inside single-quoted strings", () => {
const input = "{'key': 'value with \"quotes\"'}"
const result = repairJson(input)
expect(result.repaired).toBe(true)
expect(result.parsed).toEqual({ key: 'value with "quotes"' })
})
})
describe("unquoted keys", () => {
it("should quote unquoted keys", () => {
const input = '{key: "value"}'
const result = repairJson(input)
expect(result.repaired).toBe(true)
expect(result.parsed).toEqual({ key: "value" })
})
it("should handle multiple unquoted keys", () => {
const input = "{first: 1, second: 2, third: 3}"
const result = repairJson(input)
expect(result.repaired).toBe(true)
expect(result.parsed).toEqual({ first: 1, second: 2, third: 3 })
})
it("should handle keys with underscores and dollar signs", () => {
const input = "{_private: 1, $special: 2}"
const result = repairJson(input)
expect(result.repaired).toBe(true)
expect(result.parsed).toEqual({ _private: 1, $special: 2 })
})
})
describe("missing closing brackets", () => {
it("should add missing closing brace", () => {
const input = '{"key": "value"'
const result = repairJson(input)
expect(result.repaired).toBe(true)
expect(result.parsed).toEqual({ key: "value" })
})
it("should add missing closing bracket", () => {
const input = "[1, 2, 3"
const result = repairJson(input)
expect(result.repaired).toBe(true)
expect(result.parsed).toEqual([1, 2, 3])
})
it("should add multiple missing closures", () => {
const input = '{"outer": {"inner": [1, 2'
const result = repairJson(input)
expect(result.repaired).toBe(true)
expect(result.parsed).toEqual({ outer: { inner: [1, 2] } })
})
})
describe("prefixed text", () => {
it("should strip non-JSON prefix", () => {
const input = 'Here is the JSON: {"key": "value"}'
const result = repairJson(input)
expect(result.repaired).toBe(true)
expect(result.parsed).toEqual({ key: "value" })
})
it("should handle whitespace prefix", () => {
const input = ' {"key": "value"}'
const result = repairJson(input)
expect(result.repaired).toBe(false) // Just whitespace doesn't count as repair
expect(result.parsed).toEqual({ key: "value" })
})
})
describe("string issues", () => {
it("should handle unescaped newlines in strings", () => {
const input = '{"key": "line1\nline2"}'
const result = repairJson(input)
// The newline should be escaped
expect(result.parsed?.key).toBe("line1\nline2")
})
})
describe("combined repairs", () => {
it("should handle multiple issues at once", () => {
const input = "{key: 'value', items: [1, 2,],}"
const result = repairJson(input)
expect(result.repaired).toBe(true)
expect(result.parsed).toEqual({ key: "value", items: [1, 2] })
})
it("should handle complex malformed input", () => {
// More realistic malformed JSON with multiple issues but recoverable
const input = "{name: 'test', data: {count: 10, values: [1, 2, 3,]},}"
const result = repairJson(input)
expect(result.repaired).toBe(true)
expect(result.parsed).toEqual({
name: "test",
data: { count: 10, values: [1, 2, 3] },
})
})
})
describe("error handling", () => {
it("should return error for completely invalid input", () => {
const input = "this is not json at all"
const result = repairJson(input)
expect(result.repaired).toBe(true)
expect(result.error).toBeDefined()
expect(result.parsed).toBeUndefined()
})
it("should return error for severely malformed JSON", () => {
const input = "{{{{{{{"
const result = repairJson(input)
expect(result.repaired).toBe(true)
expect(result.error).toBeDefined()
})
})
})
describe("repairToolCallJson", () => {
it("should handle empty input", () => {
const result = repairToolCallJson("")
expect(result.repaired).toBe(true)
expect(result.parsed).toEqual({})
})
it("should handle whitespace-only input", () => {
const result = repairToolCallJson(" ")
expect(result.repaired).toBe(true)
expect(result.parsed).toEqual({})
})
it("should extract JSON from markdown code block", () => {
const input = '```json\n{"result": "Task completed"}\n```'
const result = repairToolCallJson(input)
expect(result.repaired).toBe(true)
expect(result.parsed).toEqual({ result: "Task completed" })
})
it("should extract JSON from code block without language", () => {
const input = '```\n{"result": "success"}\n```'
const result = repairToolCallJson(input)
expect(result.repaired).toBe(true)
expect(result.parsed).toEqual({ result: "success" })
})
it("should extract JSON object from mixed text", () => {
const input = 'The result is {"status": "ok", "count": 5} and that is all.'
const result = repairToolCallJson(input)
expect(result.repaired).toBe(true)
expect(result.parsed).toEqual({ status: "ok", count: 5 })
})
it("should extract JSON array from mixed text", () => {
const input = "Here are the items: [1, 2, 3] for processing."
const result = repairToolCallJson(input)
expect(result.repaired).toBe(true)
expect(result.parsed).toEqual([1, 2, 3])
})
describe("typical LLM malformation patterns", () => {
it("should repair attempt_completion with trailing comma", () => {
const input = '{"result": "I have completed the task successfully.",}'
const result = repairToolCallJson(input)
expect(result.repaired).toBe(true)
expect(result.parsed).toEqual({
result: "I have completed the task successfully.",
})
})
it("should repair tool call with unquoted keys", () => {
const input = '{command: "npm install", cwd: "/project"}'
const result = repairToolCallJson(input)
expect(result.repaired).toBe(true)
expect(result.parsed).toEqual({
command: "npm install",
cwd: "/project",
})
})
it("should repair ask_followup_question with mixed issues", () => {
const input = `{question: 'What file should I edit?', follow_up: [{text: 'src/index.ts', mode: null},]}`
const result = repairToolCallJson(input)
expect(result.repaired).toBe(true)
expect(result.parsed).toEqual({
question: "What file should I edit?",
follow_up: [{ text: "src/index.ts", mode: null }],
})
})
it("should repair read_file with missing closing bracket", () => {
const input = '{"files": [{"path": "src/app.ts"}]'
const result = repairToolCallJson(input)
expect(result.repaired).toBe(true)
expect(result.parsed).toEqual({
files: [{ path: "src/app.ts" }],
})
})
})
})
})

340
src/utils/repair-json.ts Normal file
View file

@ -0,0 +1,340 @@
/**
* JSON Repair Utility
*
* Attempts to repair malformed JSON that may be produced by LLMs.
* Common issues include:
* - Missing closing brackets/braces
* - Trailing commas
* - Unquoted keys
* - Unescaped special characters
* - Missing quotes around string values
* - Single quotes instead of double quotes
* - Control characters in strings
*
* @see Issue #10481 - Use of BAML for parsing llm output to correct malformed responses
*/
export interface RepairResult {
/** Whether repair was attempted (original JSON was invalid) */
repaired: boolean
/** The repaired JSON string (or original if repair was not needed or failed) */
json: string
/** The parsed JSON object (or undefined if parsing still failed) */
parsed?: any
/** Error message if repair failed */
error?: string
}
/**
* Attempt to repair and parse malformed JSON.
*
* @param input - The potentially malformed JSON string
* @returns RepairResult with repaired JSON and/or parsed object
*/
export function repairJson(input: string): RepairResult {
// First, try to parse as-is
try {
const parsed = JSON.parse(input)
return { repaired: false, json: input, parsed }
} catch {
// JSON is invalid, attempt repair
}
// Attempt repair
try {
const repaired = attemptRepair(input)
const parsed = JSON.parse(repaired)
return { repaired: true, json: repaired, parsed }
} catch (error) {
return {
repaired: true,
json: input,
error: `Failed to repair JSON: ${error instanceof Error ? error.message : String(error)}`,
}
}
}
/**
* Attempt to repair malformed JSON string.
*/
function attemptRepair(input: string): string {
let json = input.trim()
// Step 1: Handle common prefix issues
// Sometimes LLMs add explanatory text before JSON
json = stripNonJsonPrefix(json)
// Step 2: Handle control characters in strings
json = escapeControlCharacters(json)
// Step 3: Replace single quotes with double quotes (but preserve escaped singles)
json = replaceSingleQuotes(json)
// Step 4: Quote unquoted keys
json = quoteUnquotedKeys(json)
// Step 5: Remove trailing commas
json = removeTrailingCommas(json)
// Step 6: Balance brackets and braces
json = balanceBracketsAndBraces(json)
// Step 7: Handle common string issues
json = fixStringIssues(json)
return json
}
/**
* Strip non-JSON prefix text that LLMs sometimes add.
*/
function stripNonJsonPrefix(json: string): string {
// Find the first { or [ character
const objectStart = json.indexOf("{")
const arrayStart = json.indexOf("[")
if (objectStart === -1 && arrayStart === -1) {
// No JSON structure found, return as-is
return json
}
let startIndex: number
if (objectStart === -1) {
startIndex = arrayStart
} else if (arrayStart === -1) {
startIndex = objectStart
} else {
startIndex = Math.min(objectStart, arrayStart)
}
// Only strip if there's non-whitespace before the JSON
const prefix = json.substring(0, startIndex).trim()
if (prefix.length > 0) {
return json.substring(startIndex)
}
return json
}
/**
* Escape control characters that may be present in strings.
*/
function escapeControlCharacters(json: string): string {
// Replace unescaped control characters (except within already escaped sequences)
// eslint-disable-next-line no-control-regex
return json.replace(/[\x00-\x1f]/g, (char) => {
// Don't replace if it's a newline or tab in a reasonable context
if (char === "\n" || char === "\t" || char === "\r") {
return char
}
// Escape other control characters
return "\\u" + char.charCodeAt(0).toString(16).padStart(4, "0")
})
}
/**
* Replace single quotes with double quotes for JSON compliance.
* Handles the case where single quotes are used for strings.
*/
function replaceSingleQuotes(json: string): string {
const result: string[] = []
let inString = false
let stringChar = ""
let i = 0
while (i < json.length) {
const char = json[i]
const prevChar = i > 0 ? json[i - 1] : ""
if (inString) {
if (char === stringChar && prevChar !== "\\") {
// End of string
result.push(stringChar === "'" ? '"' : char)
inString = false
} else if (char === '"' && stringChar === "'") {
// Escape double quotes inside single-quoted strings
result.push('\\"')
} else {
result.push(char)
}
} else {
if (char === '"' || char === "'") {
inString = true
stringChar = char
result.push('"')
} else {
result.push(char)
}
}
i++
}
return result.join("")
}
/**
* Quote unquoted keys in JSON objects.
* Handles: {key: "value"} -> {"key": "value"}
*/
function quoteUnquotedKeys(json: string): string {
// Match unquoted keys followed by colon
// This regex looks for word characters not preceded by quotes
return json.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)(\s*:)/g, '$1"$2"$3')
}
/**
* Remove trailing commas before closing brackets/braces.
* Handles: [1, 2, 3,] -> [1, 2, 3]
*/
function removeTrailingCommas(json: string): string {
// Remove trailing commas before ] or }
return json.replace(/,(\s*[}\]])/g, "$1")
}
/**
* Balance unmatched brackets and braces.
*/
function balanceBracketsAndBraces(json: string): string {
const stack: string[] = []
let inString = false
let stringChar = ""
for (let i = 0; i < json.length; i++) {
const char = json[i]
const prevChar = i > 0 ? json[i - 1] : ""
if (inString) {
if (char === stringChar && prevChar !== "\\") {
inString = false
}
continue
}
if (char === '"' || char === "'") {
inString = true
stringChar = char
continue
}
if (char === "{" || char === "[") {
stack.push(char)
} else if (char === "}") {
if (stack.length > 0 && stack[stack.length - 1] === "{") {
stack.pop()
}
} else if (char === "]") {
if (stack.length > 0 && stack[stack.length - 1] === "[") {
stack.pop()
}
}
}
// If we're still in a string, try to close it
let result = json
if (inString) {
result += stringChar
}
// Add missing closing brackets/braces
while (stack.length > 0) {
const open = stack.pop()
if (open === "{") {
result += "}"
} else if (open === "[") {
result += "]"
}
}
return result
}
/**
* Fix common string-related issues.
*/
function fixStringIssues(json: string): string {
// Fix unescaped newlines in strings
// This is tricky because we need to identify strings first
const result: string[] = []
let inString = false
let stringChar = ""
let i = 0
while (i < json.length) {
const char = json[i]
const prevChar = i > 0 ? json[i - 1] : ""
if (inString) {
if (char === stringChar && prevChar !== "\\") {
// End of string
result.push(char)
inString = false
} else if (char === "\n" && prevChar !== "\\") {
// Unescaped newline in string - escape it
result.push("\\n")
} else if (char === "\r" && prevChar !== "\\") {
// Unescaped carriage return in string - escape it
result.push("\\r")
} else if (char === "\t" && prevChar !== "\\") {
// Unescaped tab in string - escape it
result.push("\\t")
} else {
result.push(char)
}
} else {
if (char === '"') {
inString = true
stringChar = char
}
result.push(char)
}
i++
}
return result.join("")
}
/**
* Repair JSON specifically for LLM tool call arguments.
* This is a specialized version that applies additional heuristics
* for the tool call context.
*
* @param input - The potentially malformed tool call arguments JSON
* @returns RepairResult with repaired JSON and/or parsed object
*/
export function repairToolCallJson(input: string): RepairResult {
// Handle empty input
if (!input || input.trim() === "") {
return { repaired: true, json: "{}", parsed: {} }
}
// First try standard repair
const result = repairJson(input)
if (result.parsed !== undefined) {
return result
}
// Additional repair strategies for tool calls
// Strategy 1: Try to extract JSON from markdown code blocks
const codeBlockMatch = input.match(/```(?:json)?\s*([\s\S]*?)\s*```/)
if (codeBlockMatch) {
const extracted = codeBlockMatch[1].trim()
const extractedResult = repairJson(extracted)
if (extractedResult.parsed !== undefined) {
return { ...extractedResult, repaired: true }
}
}
// Strategy 2: Try to find and extract JSON object/array
const jsonMatch = input.match(/(\{[\s\S]*\}|\[[\s\S]*\])/)
if (jsonMatch) {
const extracted = jsonMatch[1]
const extractedResult = repairJson(extracted)
if (extractedResult.parsed !== undefined) {
return { ...extractedResult, repaired: true }
}
}
// Return the original repair result if nothing worked
return result
}

View file

@ -242,6 +242,7 @@ describe("mergeExtensionState", () => {
nativeToolCalling: false,
multipleNativeToolCalls: false,
customTools: false,
llmResponseRepair: false,
} as Record<ExperimentId, boolean>,
checkpointTimeout: DEFAULT_CHECKPOINT_TIMEOUT_SECONDS + 5,
}
@ -266,6 +267,7 @@ describe("mergeExtensionState", () => {
nativeToolCalling: false,
multipleNativeToolCalls: false,
customTools: false,
llmResponseRepair: false,
})
})
})