Skip to content
Closed
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
6 changes: 6 additions & 0 deletions packages/types/src/experiment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export const experimentIds = [
"runSlashCommand",
"multipleNativeToolCalls",
"customTools",
"malformedJsonRepair",
] as const

export const experimentIdsSchema = z.enum(experimentIds)
Expand All @@ -32,6 +33,11 @@ export const experimentsSchema = z.object({
runSlashCommand: z.boolean().optional(),
multipleNativeToolCalls: z.boolean().optional(),
customTools: z.boolean().optional(),
/**
* Enable automatic repair of malformed JSON in LLM tool call responses.
* Useful for models like Grok that struggle with strict JSON formatting.
*/
malformedJsonRepair: z.boolean().optional(),
})

export type Experiments = z.infer<typeof experimentsSchema>
Expand Down
12 changes: 11 additions & 1 deletion pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

46 changes: 45 additions & 1 deletion src/core/assistant-message/NativeToolCallParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ import { parseJSON } from "partial-json"
import { type ToolName, toolNames, type FileEntry } from "@roo-code/types"
import { customToolRegistry } from "@roo-code/core"

import { repairJson } from "../../utils/json-repair"
import { logger } from "../../utils/logging"

import {
type ToolUse,
type McpToolUse,
Expand Down Expand Up @@ -51,6 +54,25 @@ export type ToolCallStreamEvent = ApiStreamToolCallStartChunk | ApiStreamToolCal
* provider-level raw chunks into start/delta/end events.
*/
export class NativeToolCallParser {
// Configuration for malformed JSON repair (experimental feature)
private static malformedJsonRepairEnabled = false

/**
* Enable or disable malformed JSON repair.
* When enabled, the parser will attempt to repair malformed JSON
* in tool call arguments before parsing.
*/
public static setMalformedJsonRepairEnabled(enabled: boolean): void {
this.malformedJsonRepairEnabled = enabled
}

/**
* Check if malformed JSON repair is enabled.
*/
public static isMalformedJsonRepairEnabled(): boolean {
return this.malformedJsonRepairEnabled
}

// 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<
Expand Down Expand Up @@ -593,7 +615,29 @@ export class NativeToolCallParser {

try {
// Parse the arguments JSON string
const args = toolCall.arguments === "" ? {} : JSON.parse(toolCall.arguments)
// If malformed JSON repair is enabled, attempt to repair before parsing
let args: Record<string, unknown>
if (toolCall.arguments === "") {
args = {}
} else {
try {
args = JSON.parse(toolCall.arguments)
} catch (parseError) {
// JSON parsing failed - attempt repair if enabled
if (this.malformedJsonRepairEnabled) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This repair logic only applies to regular tool calls via parseToolCall(), but parseDynamicMcpTool() (line 910) still uses plain JSON.parse(toolCall.arguments || "{}") without the repair fallback. MCP tool calls from models like Grok would still fail with malformed JSON even when this experiment is enabled. Consider adding the same repair logic there for consistency.

Fix it with Roo Code or mention @roomote and request a fix.

const repaired = repairJson(toolCall.arguments)
if (repaired) {
logger.info(`[NativeToolCallParser] Repaired malformed JSON for tool ${toolCall.name}`)
args = JSON.parse(repaired)
} else {
// Repair failed, re-throw original error
throw parseError
}
} else {
throw parseError
}
}
}

// Build legacy params object for backward compatibility with XML protocol and UI.
// Native execution path uses nativeArgs instead, which has proper typing.
Expand Down
10 changes: 10 additions & 0 deletions src/core/task/Task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -541,6 +541,16 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
})
}

// Check malformed JSON repair experiment asynchronously.
// This enables repair of malformed JSON in tool call arguments from models like Grok.
provider.getState().then((state) => {
const isMalformedJsonRepairEnabled = experiments.isEnabled(
state.experiments ?? {},
EXPERIMENT_IDS.MALFORMED_JSON_REPAIR,
)
NativeToolCallParser.setMalformedJsonRepairEnabled(isMalformedJsonRepairEnabled)
})

this.toolRepetitionDetector = new ToolRepetitionDetector(this.consecutiveMistakeLimit)

// Initialize todo list if provided
Expand Down
1 change: 1 addition & 0 deletions src/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -466,6 +466,7 @@
"i18next": "^25.0.0",
"ignore": "^7.0.3",
"isbinaryfile": "^5.0.2",
"jsonrepair": "^3.13.1",
"jwt-decode": "^4.0.0",
"lodash.debounce": "^4.0.8",
"mammoth": "^1.9.1",
Expand Down
3 changes: 3 additions & 0 deletions src/shared/__tests__/experiments.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ describe("experiments", () => {
runSlashCommand: false,
multipleNativeToolCalls: false,
customTools: false,
malformedJsonRepair: false,
}
expect(Experiments.isEnabled(experiments, EXPERIMENT_IDS.POWER_STEERING)).toBe(false)
})
Expand All @@ -46,6 +47,7 @@ describe("experiments", () => {
runSlashCommand: false,
multipleNativeToolCalls: false,
customTools: false,
malformedJsonRepair: false,
}
expect(Experiments.isEnabled(experiments, EXPERIMENT_IDS.POWER_STEERING)).toBe(true)
})
Expand All @@ -59,6 +61,7 @@ describe("experiments", () => {
runSlashCommand: false,
multipleNativeToolCalls: false,
customTools: false,
malformedJsonRepair: false,
}
expect(Experiments.isEnabled(experiments, EXPERIMENT_IDS.POWER_STEERING)).toBe(false)
})
Expand Down
2 changes: 2 additions & 0 deletions src/shared/experiments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export const EXPERIMENT_IDS = {
RUN_SLASH_COMMAND: "runSlashCommand",
MULTIPLE_NATIVE_TOOL_CALLS: "multipleNativeToolCalls",
CUSTOM_TOOLS: "customTools",
MALFORMED_JSON_REPAIR: "malformedJsonRepair",
} as const satisfies Record<string, ExperimentId>

type _AssertExperimentIds = AssertEqual<Equals<ExperimentId, Values<typeof EXPERIMENT_IDS>>>
Expand All @@ -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 },
MALFORMED_JSON_REPAIR: { enabled: false },
}

export const experimentDefault = Object.fromEntries(
Expand Down
Loading
Loading