mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
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:
parent
861139ca24
commit
aff2ed2081
7 changed files with 736 additions and 2 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
308
src/utils/__tests__/repair-json.spec.ts
Normal file
308
src/utils/__tests__/repair-json.spec.ts
Normal 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
340
src/utils/repair-json.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue