diff --git a/packages/opencode/src/tool/tool.ts b/packages/opencode/src/tool/tool.ts index 78ab325af41..dd032e87395 100644 --- a/packages/opencode/src/tool/tool.ts +++ b/packages/opencode/src/tool/tool.ts @@ -44,42 +44,97 @@ export namespace Tool { export type InferParameters = T extends Info ? z.infer

: never export type InferMetadata = T extends Info ? M : never + function coerceStringValues(input: Record): Record { + const result: Record = {} + for (const [key, value] of Object.entries(input)) { + if (typeof value !== "string") { + result[key] = value + continue + } + if (/^[\[{]/.test(value)) { + try { + result[key] = JSON.parse(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 + } + 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 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 (hasCoercibleTypeError) { + const coercedArgs = coerceStringValues(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 },