Просмотр исходного кода

experimental batch tool (#2983)

Co-authored-by: GitHub Action <[email protected]>
Baptiste Cavallo 3 месяцев назад
Родитель
Сommit
1056b36eae

+ 1 - 0
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(),
     })

+ 108 - 0
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 `<tool_result name="${r.tool}">\n${r.result.output}\n</tool_result>`
+        }
+        const errorMessage = r.error instanceof Error ? r.error.message : String(r.error)
+        return `<tool_result name="${r.tool}">\nError: ${errorMessage}\n</tool_result>`
+      })
+
+      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 })),
+        },
+      }
+    },
+  }
+})

+ 28 - 0
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.

+ 8 - 4
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<Tool.Info[]> {
     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,
     ]

+ 9 - 1
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