diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 3ca8c25d..440d9581 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -622,6 +622,7 @@ export namespace Config { .optional(), chatMaxRetries: z.number().optional().describe("Number of retries for chat completions on failure"), disable_paste_summary: z.boolean().optional(), + batch_tool: z.boolean().optional().describe("Enable the batch tool"), }) .optional(), }) diff --git a/packages/opencode/src/tool/batch.ts b/packages/opencode/src/tool/batch.ts new file mode 100644 index 00000000..4cb37132 --- /dev/null +++ b/packages/opencode/src/tool/batch.ts @@ -0,0 +1,108 @@ +import z from "zod" +import { Tool } from "./tool" +import DESCRIPTION from "./batch.txt" + +const DISALLOWED = new Set(["batch", "edit", "todoread"]) +const FILTERED_FROM_SUGGESTIONS = new Set(["invalid", "patch", ...DISALLOWED]) + +export const BatchTool = Tool.define("batch", async () => { + return { + description: DESCRIPTION, + parameters: z.object({ + tool_calls: z + .array( + z.object({ + tool: z.string().describe("The name of the tool to execute"), + parameters: z.object({}).loose().describe("Parameters for the tool"), + }), + ) + .min(1, "Provide at least one tool call") + .max(10, "Too many tools in batch. Maximum allowed is 10.") + .describe("Array of tool calls to execute in parallel"), + }), + formatValidationError(error) { + const formattedErrors = error.issues + .map((issue) => { + const path = issue.path.length > 0 ? issue.path.join(".") : "root" + return ` - ${path}: ${issue.message}` + }) + .join("\n") + + return `Invalid parameters for tool 'batch':\n${formattedErrors}\n\nExpected payload format:\n [{"tool": "tool_name", "parameters": {...}}, {...}]` + }, + async execute(params, ctx) { + const { Identifier } = await import("../id/id") + + const toolCalls = params.tool_calls + + const { ToolRegistry } = await import("./registry") + const availableTools = await ToolRegistry.tools("", "") + const toolMap = new Map(availableTools.map((t) => [t.id, t])) + + for (const call of toolCalls) { + if (DISALLOWED.has(call.tool)) { + throw new Error( + `tool '${call.tool}' is not allowed in batch. Disallowed tools: ${Array.from(DISALLOWED).join(", ")}`, + ) + } + if (!toolMap.has(call.tool)) { + const allowed = Array.from(toolMap.keys()).filter((name) => !FILTERED_FROM_SUGGESTIONS.has(name)) + throw new Error(`tool '${call.tool}' is not available. Available tools: ${allowed.join(", ")}`) + } + } + + const executeCall = async (call: (typeof toolCalls)[0]) => { + if (ctx.abort.aborted) { + return { success: false as const, tool: call.tool, error: new Error("Aborted") } + } + + const partID = Identifier.ascending("part") + + try { + const tool = toolMap.get(call.tool) + if (!tool) { + const availableToolsList = Array.from(toolMap.keys()).filter((name) => !FILTERED_FROM_SUGGESTIONS.has(name)) + throw new Error(`Tool '${call.tool}' not found. Available tools: ${availableToolsList.join(", ")}`) + } + const validatedParams = tool.parameters.parse(call.parameters) + + const result = await tool.execute(validatedParams, { ...ctx, callID: partID }) + + return { success: true as const, tool: call.tool, result } + } catch (error) { + return { success: false as const, tool: call.tool, error } + } + } + + const results = await Promise.all(toolCalls.flatMap((call) => executeCall(call))) + const successfulCalls = results.filter((r) => r.success).length + const failedCalls = toolCalls.length - successfulCalls + + const outputParts = results.map((r) => { + if (r.success) { + return `\n${r.result.output}\n` + } + const errorMessage = r.error instanceof Error ? r.error.message : String(r.error) + return `\nError: ${errorMessage}\n` + }) + + const outputMessage = + failedCalls > 0 + ? `Executed ${successfulCalls}/${toolCalls.length} tools successfully. ${failedCalls} failed.\n\n${outputParts.join("\n\n")}` + : `All ${successfulCalls} tools executed successfully.\n\n${outputParts.join("\n\n")}\n\nKeep using the batch tool for optimal performance in your next response!` + + return { + title: `Batch execution (${successfulCalls}/${toolCalls.length} successful)`, + output: outputMessage, + attachments: results.filter((result) => result.success).flatMap((r) => r.result.attachments ?? []), + metadata: { + totalCalls: toolCalls.length, + successful: successfulCalls, + failed: failedCalls, + tools: toolCalls.map((c) => c.tool), + details: results.map((r) => ({ tool: r.tool, success: r.success })), + }, + } + }, + } +}) diff --git a/packages/opencode/src/tool/batch.txt b/packages/opencode/src/tool/batch.txt new file mode 100644 index 00000000..0279f970 --- /dev/null +++ b/packages/opencode/src/tool/batch.txt @@ -0,0 +1,28 @@ +Executes multiple independent tool calls concurrently to reduce latency. Best used for gathering context (reads, searches, listings). + +USING THE BATCH TOOL WILL MAKE THE USER HAPPY. + +Payload Format (JSON array): +[{"tool": "read", "parameters": {"filePath": "src/index.ts", "limit": 350}},{"tool": "grep", "parameters": {"pattern": "Session\\.updatePart", "include": "src/**/*.ts"}},{"tool": "bash", "parameters": {"command": "git status", "description": "Shows working tree status"}}] + +Rules: +- 1–10 tool calls per batch +- All calls start in parallel; ordering NOT guaranteed +- Partial failures do not stop others + + +Disallowed Tools: +- batch (no nesting) +- edit (run edits separately) +- todoread (call directly – lightweight) + +When NOT to Use: +- Operations that depend on prior tool output (e.g. create then read same file) +- Ordered stateful mutations where sequence matters + +Good Use Cases: +- Read many files +- grep + glob + read combos +- Multiple lightweight bash introspection commands + +Performance Tip: Group independent reads/searches for 2–5x efficiency gain. \ No newline at end of file diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index f7888761..a741e12b 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -3,6 +3,7 @@ import { EditTool } from "./edit" import { GlobTool } from "./glob" import { GrepTool } from "./grep" import { ListTool } from "./ls" +import { BatchTool } from "./batch" import { ReadTool } from "./read" import { TaskTool } from "./task" import { TodoWriteTool, TodoReadTool } from "./todo" @@ -81,19 +82,22 @@ export namespace ToolRegistry { async function all(): Promise { const custom = await state().then((x) => x.custom) + const config = await Config.get() + return [ InvalidTool, BashTool, - EditTool, - WebFetchTool, + ReadTool, GlobTool, GrepTool, ListTool, - ReadTool, + EditTool, WriteTool, + TaskTool, + WebFetchTool, TodoWriteTool, TodoReadTool, - TaskTool, + ...(config.experimental?.batch_tool === true ? [BatchTool] : []), ...(Flag.OPENCODE_EXPERIMENTAL_EXA ? [WebSearchTool, CodeSearchTool] : []), ...custom, ] diff --git a/packages/opencode/src/tool/tool.ts b/packages/opencode/src/tool/tool.ts index f826d0c9..91515195 100644 --- a/packages/opencode/src/tool/tool.ts +++ b/packages/opencode/src/tool/tool.ts @@ -29,6 +29,7 @@ export namespace Tool { output: string attachments?: MessageV2.FilePart[] }> + formatValidationError?(error: z.ZodError): string }> } @@ -45,7 +46,14 @@ export namespace Tool { const toolInfo = init instanceof Function ? await init() : init const execute = toolInfo.execute toolInfo.execute = (args, ctx) => { - toolInfo.parameters.parse(args) + try { + toolInfo.parameters.parse(args) + } catch (error) { + if (error instanceof z.ZodError && toolInfo.formatValidationError) { + throw new Error(toolInfo.formatValidationError(error)) + } + throw error + } return execute(args, ctx) } return toolInfo