From bb94b481f8cd795e0076489f59ea9b1b0f8e7c51 Mon Sep 17 00:00:00 2001 From: crottolo Date: Fri, 9 Jan 2026 19:48:33 +0100 Subject: [PATCH 1/2] fix(opencode): coerce stringified JSON arrays/objects in tool parameters When LLMs return tool call arguments, they sometimes serialize arrays and objects as JSON strings instead of proper JSON values. This causes Zod validation to fail with 'expected array, received string'. This fix adds automatic coercion: if validation fails because a string was received where an array/object was expected, we attempt to parse the string as JSON and retry validation. Affected tools: Todowrite, Question, and any tool with array/object params. Applies to all providers (Anthropic, OpenAI, Google, etc.). --- packages/opencode/src/tool/tool.ts | 92 ++++++++++++++++++++++-------- 1 file changed, 69 insertions(+), 23 deletions(-) diff --git a/packages/opencode/src/tool/tool.ts b/packages/opencode/src/tool/tool.ts index 78ab325af41..86ac40fbd8e 100644 --- a/packages/opencode/src/tool/tool.ts +++ b/packages/opencode/src/tool/tool.ts @@ -44,42 +44,88 @@ export namespace Tool { export type InferParameters = T extends Info ? z.infer

: never export type InferMetadata = T extends Info ? M : never + function coerceJsonStrings(input: Record): Record { + const result: Record = {} + for (const [key, value] of Object.entries(input)) { + if (typeof value === "string" && /^[\[{]/.test(value)) { + try { + result[key] = JSON.parse(value) + } catch { + result[key] = value + } + } else { + result[key] = value + } + } + return result + } + export function define( id: string, init: Info["init"] | Awaited["init"]>>, ): Info { + id = id.replace(/\w\S*/g, (text) => text.charAt(0).toUpperCase() + text.substring(1).toLowerCase()) return { id, init: async (initCtx) => { const toolInfo = init instanceof Function ? await init(initCtx) : init - const execute = toolInfo.execute + const originalExecute = toolInfo.execute toolInfo.execute = async (args, ctx) => { - try { - toolInfo.parameters.parse(args) - } catch (error) { - if (error instanceof z.ZodError && toolInfo.formatValidationError) { - throw new Error(toolInfo.formatValidationError(error), { cause: error }) + const firstTry = toolInfo.parameters.safeParse(args) + + if (firstTry.success) { + const result = await originalExecute(args, ctx) + if (result.metadata.truncated !== undefined) { + return result + } + const truncated = await Truncate.output(result.output, {}, initCtx?.agent) + return { + ...result, + output: truncated.content, + metadata: { + ...result.metadata, + truncated: truncated.truncated, + ...(truncated.truncated && { outputPath: truncated.outputPath }), + }, } - throw new Error( - `The ${id} tool was called with invalid arguments: ${error}.\nPlease rewrite the input so it satisfies the expected schema.`, - { cause: error }, - ) } - const result = await execute(args, ctx) - // skip truncation for tools that handle it themselves - if (result.metadata.truncated !== undefined) { - return result + + const hasStringTypeError = firstTry.error.issues.some( + (issue) => + issue.code === "invalid_type" && + ((issue as { expected?: string }).expected === "array" || + (issue as { expected?: string }).expected === "object"), + ) + + if (hasStringTypeError) { + const coercedArgs = coerceJsonStrings(args as Record) + const secondTry = toolInfo.parameters.safeParse(coercedArgs) + + if (secondTry.success) { + const result = await originalExecute(coercedArgs as z.infer, ctx) + if (result.metadata.truncated !== undefined) { + return result + } + const truncated = await Truncate.output(result.output, {}, initCtx?.agent) + return { + ...result, + output: truncated.content, + metadata: { + ...result.metadata, + truncated: truncated.truncated, + ...(truncated.truncated && { outputPath: truncated.outputPath }), + }, + } + } } - const truncated = await Truncate.output(result.output, {}, initCtx?.agent) - return { - ...result, - output: truncated.content, - metadata: { - ...result.metadata, - truncated: truncated.truncated, - ...(truncated.truncated && { outputPath: truncated.outputPath }), - }, + + if (toolInfo.formatValidationError) { + throw new Error(toolInfo.formatValidationError(firstTry.error), { cause: firstTry.error }) } + throw new Error( + `The ${id} tool was called with invalid arguments: ${firstTry.error}.\nPlease rewrite the input so it satisfies the expected schema.`, + { cause: firstTry.error }, + ) } return toolInfo }, From d70d03d2b2c33e319b5dd8914f04db971fae2364 Mon Sep 17 00:00:00 2001 From: crottolo Date: Fri, 9 Jan 2026 21:03:17 +0100 Subject: [PATCH 2/2] fix(opencode): extend coercion to handle number and boolean strings LLMs also send numbers as strings (e.g., timeout: "180000") and booleans as strings (e.g., enabled: "true"). Extended coercion to handle these cases as well. Now handles: - array/object: JSON.parse() - number: Number() for numeric strings - boolean: "true"/"false" conversion --- packages/opencode/src/tool/tool.ts | 39 ++++++++++++++++++------------ 1 file changed, 24 insertions(+), 15 deletions(-) diff --git a/packages/opencode/src/tool/tool.ts b/packages/opencode/src/tool/tool.ts index 86ac40fbd8e..dd032e87395 100644 --- a/packages/opencode/src/tool/tool.ts +++ b/packages/opencode/src/tool/tool.ts @@ -44,18 +44,28 @@ export namespace Tool { export type InferParameters = T extends Info ? z.infer

: never export type InferMetadata = T extends Info ? M : never - function coerceJsonStrings(input: Record): Record { + function coerceStringValues(input: Record): Record { const result: Record = {} for (const [key, value] of Object.entries(input)) { - if (typeof value === "string" && /^[\[{]/.test(value)) { + if (typeof value !== "string") { + result[key] = value + continue + } + if (/^[\[{]/.test(value)) { try { result[key] = JSON.parse(value) - } catch { - result[key] = value - } - } else { - result[key] = value + continue + } catch {} } + if (/^-?\d+(\.\d+)?$/.test(value)) { + result[key] = Number(value) + continue + } + if (value === "true" || value === "false") { + result[key] = value === "true" + continue + } + result[key] = value } return result } @@ -90,15 +100,14 @@ export namespace Tool { } } - const hasStringTypeError = firstTry.error.issues.some( - (issue) => - issue.code === "invalid_type" && - ((issue as { expected?: string }).expected === "array" || - (issue as { expected?: string }).expected === "object"), - ) + const hasCoercibleTypeError = firstTry.error.issues.some((issue) => { + if (issue.code !== "invalid_type") return false + const expected = (issue as { expected?: string }).expected + return expected === "array" || expected === "object" || expected === "number" || expected === "boolean" + }) - if (hasStringTypeError) { - const coercedArgs = coerceJsonStrings(args as Record) + if (hasCoercibleTypeError) { + const coercedArgs = coerceStringValues(args as Record) const secondTry = toolInfo.parameters.safeParse(coercedArgs) if (secondTry.success) {