mirror of
https://github.com/aljazceru/opencode.git
synced 2025-12-19 16:54:22 +01:00
experimental batch tool (#2983)
Co-authored-by: GitHub Action <action@github.com>
This commit is contained in:
@@ -622,6 +622,7 @@ export namespace Config {
|
|||||||
.optional(),
|
.optional(),
|
||||||
chatMaxRetries: z.number().optional().describe("Number of retries for chat completions on failure"),
|
chatMaxRetries: z.number().optional().describe("Number of retries for chat completions on failure"),
|
||||||
disable_paste_summary: z.boolean().optional(),
|
disable_paste_summary: z.boolean().optional(),
|
||||||
|
batch_tool: z.boolean().optional().describe("Enable the batch tool"),
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
})
|
})
|
||||||
|
|||||||
108
packages/opencode/src/tool/batch.ts
Normal file
108
packages/opencode/src/tool/batch.ts
Normal file
@@ -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
packages/opencode/src/tool/batch.txt
Normal file
28
packages/opencode/src/tool/batch.txt
Normal file
@@ -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.
|
||||||
@@ -3,6 +3,7 @@ import { EditTool } from "./edit"
|
|||||||
import { GlobTool } from "./glob"
|
import { GlobTool } from "./glob"
|
||||||
import { GrepTool } from "./grep"
|
import { GrepTool } from "./grep"
|
||||||
import { ListTool } from "./ls"
|
import { ListTool } from "./ls"
|
||||||
|
import { BatchTool } from "./batch"
|
||||||
import { ReadTool } from "./read"
|
import { ReadTool } from "./read"
|
||||||
import { TaskTool } from "./task"
|
import { TaskTool } from "./task"
|
||||||
import { TodoWriteTool, TodoReadTool } from "./todo"
|
import { TodoWriteTool, TodoReadTool } from "./todo"
|
||||||
@@ -81,19 +82,22 @@ export namespace ToolRegistry {
|
|||||||
|
|
||||||
async function all(): Promise<Tool.Info[]> {
|
async function all(): Promise<Tool.Info[]> {
|
||||||
const custom = await state().then((x) => x.custom)
|
const custom = await state().then((x) => x.custom)
|
||||||
|
const config = await Config.get()
|
||||||
|
|
||||||
return [
|
return [
|
||||||
InvalidTool,
|
InvalidTool,
|
||||||
BashTool,
|
BashTool,
|
||||||
EditTool,
|
ReadTool,
|
||||||
WebFetchTool,
|
|
||||||
GlobTool,
|
GlobTool,
|
||||||
GrepTool,
|
GrepTool,
|
||||||
ListTool,
|
ListTool,
|
||||||
ReadTool,
|
EditTool,
|
||||||
WriteTool,
|
WriteTool,
|
||||||
|
TaskTool,
|
||||||
|
WebFetchTool,
|
||||||
TodoWriteTool,
|
TodoWriteTool,
|
||||||
TodoReadTool,
|
TodoReadTool,
|
||||||
TaskTool,
|
...(config.experimental?.batch_tool === true ? [BatchTool] : []),
|
||||||
...(Flag.OPENCODE_EXPERIMENTAL_EXA ? [WebSearchTool, CodeSearchTool] : []),
|
...(Flag.OPENCODE_EXPERIMENTAL_EXA ? [WebSearchTool, CodeSearchTool] : []),
|
||||||
...custom,
|
...custom,
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export namespace Tool {
|
|||||||
output: string
|
output: string
|
||||||
attachments?: MessageV2.FilePart[]
|
attachments?: MessageV2.FilePart[]
|
||||||
}>
|
}>
|
||||||
|
formatValidationError?(error: z.ZodError): string
|
||||||
}>
|
}>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,7 +46,14 @@ export namespace Tool {
|
|||||||
const toolInfo = init instanceof Function ? await init() : init
|
const toolInfo = init instanceof Function ? await init() : init
|
||||||
const execute = toolInfo.execute
|
const execute = toolInfo.execute
|
||||||
toolInfo.execute = (args, ctx) => {
|
toolInfo.execute = (args, ctx) => {
|
||||||
|
try {
|
||||||
toolInfo.parameters.parse(args)
|
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 execute(args, ctx)
|
||||||
}
|
}
|
||||||
return toolInfo
|
return toolInfo
|
||||||
|
|||||||
Reference in New Issue
Block a user