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