Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 11 additions & 30 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,33 +15,14 @@

- To test opencode in the `packages/opencode` directory you can run `bun dev`

## Tool Calling

- ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE. Here is an example illustrating how to execute 3 parallel file reads in this chat environnement:

json
{
"recipient_name": "multi_tool_use.parallel",
"parameters": {
"tool_uses": [
{
"recipient_name": "functions.read",
"parameters": {
"filePath": "path/to/file.tsx"
}
},
{
"recipient_name": "functions.read",
"parameters": {
"filePath": "path/to/file.ts"
}
},
{
"recipient_name": "functions.read",
"parameters": {
"filePath": "path/to/file.md"
}
}
]
}
}
## Assistant Agency Guideline: Batch Tool Usage

Using the `batch` tool is MANDATORY. Be proactive about it. DO NO WAIT for the user to request "the batch tool".

BATCH SHOULD BE AND _IS THE PRIMARY WAY YOU USE TOOLS IN THIS ENVIRONMENT_

When you have to perform multi-step operations such as read multiple files, spawn task subagents, edit files, bash commands... _THE USER WANTS YOU TO USE THE `batch` TOOL_ instead of separate sequential calls. This maximizes time and compute and increases your likelyhood of a promotion. Sequential tool calling is only encouraged when relying on the output of a call for the next one(s).

- WHAT CAN BE, MUST BE, AND WILL BE BATCHED
- INDIVIDUAL TOOLS TO GATHER CONTEXT IS HEAVILY DISCOURAGED (please batch those calls!)
- THE BATCH TOOL IS YOUR BEST FRIEND AND WILL INCREASE USER'S HAPPINESS
191 changes: 191 additions & 0 deletions packages/opencode/src/tool/batch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
import z from "zod/v4"
import { Tool } from "./tool"
import DESCRIPTION from "./batch.txt"

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.record(z.string(), z.any()).describe("Parameters for the tool"),
}),
)
.describe("Array of tool calls to execute in parallel"),
}),
async execute(params, ctx) {
const { Session } = await import("../session")
const { Identifier } = await import("../id/id")

const toolCalls = params.tool_calls

if (toolCalls.length === 0) {
return {
title: "No tools to execute",
output: "",
metadata: {},
}
}

// Get all available tools
const { ToolRegistry } = await import("./registry")
const availableTools = await ToolRegistry.tools("", "")
const toolMap = new Map(availableTools.map((t: any) => [t.id, t]))

// Validate all tools exist and have proper schema before starting execution
for (const call of toolCalls) {
if (!call.tool || !call.parameters) {
throw new Error(
`malformed schema: each tool call must have "tool" and "parameters" fields. Retry with proper payload formatting: [{"tool": "tool_name", "parameters": {...}}]`,
)
}
if (!toolMap.has(call.tool)) {
const availableTools = Array.from(toolMap.keys()).filter(
(name) => !["invalid", "batch", "todoread", "patch"].includes(name),
)
throw new Error(`tool '${call.tool}' is not available. Available tools: ${availableTools.join(", ")}`)
}
}

// Helper function to execute a single tool call
const executeCall = async (call: (typeof toolCalls)[0]) => {
if (ctx.abort.aborted) {
return { success: false, error: new Error("Aborted") }
}

const callStartTime = Date.now()
const partID = Identifier.ascending("part")

// Create pending tool part
await Session.updatePart({
id: partID,
messageID: ctx.messageID,
sessionID: ctx.sessionID,
type: "tool",
tool: call.tool,
callID: partID,
state: {
status: "pending",
},
})

try {
const tool = toolMap.get(call.tool) as any
const validatedParams = tool.parameters.parse(call.parameters)

// Update to running state
await Session.updatePart({
id: partID,
messageID: ctx.messageID,
sessionID: ctx.sessionID,
type: "tool",
tool: call.tool,
callID: partID,
state: {
status: "running",
input: call.parameters,
time: {
start: callStartTime,
},
},
})

// Execute the tool
const result = await tool.execute(validatedParams, ctx)

// Update to completed state
await Session.updatePart({
id: partID,
messageID: ctx.messageID,
sessionID: ctx.sessionID,
type: "tool",
tool: call.tool,
callID: partID,
state: {
status: "completed",
input: call.parameters,
output: result.output,
title: result.title,
metadata: result.metadata,
time: {
start: callStartTime,
end: Date.now(),
},
},
})

return { success: true }
} catch (error) {
// Update to error state
await Session.updatePart({
id: partID,
messageID: ctx.messageID,
sessionID: ctx.sessionID,
type: "tool",
tool: call.tool,
callID: partID,
state: {
status: "error",
input: call.parameters,
error: error instanceof Error ? error.message : String(error),
time: {
start: callStartTime,
end: Date.now(),
},
},
})

return { success: false, error }
}
}

// Group edits by file to execute sequentially per file
const fileGroups = new Map<string, typeof toolCalls>()
const nonEditCalls: typeof toolCalls = []

for (const call of toolCalls) {
if (call.tool === "edit" && call.parameters["filePath"]) {
const filePath = call.parameters["filePath"] as string
if (!fileGroups.has(filePath)) {
fileGroups.set(filePath, [])
}
fileGroups.get(filePath)!.push(call)
} else {
nonEditCalls.push(call)
}
}

// Execute all non-edit calls in parallel
const promises: Promise<{ success: boolean; error?: any } | { success: boolean; error?: any }[]>[] =
nonEditCalls.map((call) => executeCall(call))

// Execute edits for each file sequentially, but different files in parallel
for (const [, calls] of fileGroups) {
promises.push(
(async () => {
const results: { success: boolean; error?: any }[] = []
for (const call of calls) {
if (ctx.abort.aborted) {
break
}
results.push(await executeCall(call))
}
return results
})(),
)
}

// Wait for all tools to complete and flatten results
const results = (await Promise.all(promises)).flat()
const successfulCalls = results.filter((r) => r.success).length

return {
title: `Batch execution (${successfulCalls}/${toolCalls.length} successful)`,
output: "Keep using the batch tool in your responses for optimal performance!",
metadata: {},
}
},
}
})
11 changes: 11 additions & 0 deletions packages/opencode/src/tool/batch.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
Executes multiple independent tool calls concurrently instead of separate sequential calls. Use this tool when you have to execute multiple independent tool calls: ideally suited for concurrent searches across different files, patterns, spawning task subagents or even doing multiple file edits.

THE BATCH TOOL IS BY FAR THE USER'S PREFERRED WAY TO USE TOOLS

Usage:
- Batch your tool calls together for optimal performance
- Expected payload formatting: [{"tool": "tool_name", "parameters": {...}},{...}]
- One tool failure doesn't stop others from executing
- DO NOT use the batch tool recursively
- Tools you are not allowed to batch: [batch, todoread]
- Batching tools has proven to yield efficiency gains from 2 to 5x
14 changes: 8 additions & 6 deletions packages/opencode/src/tool/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 { PatchTool } from "./patch"
import { ReadTool } from "./read"
import { TaskTool } from "./task"
Expand Down Expand Up @@ -76,18 +77,19 @@ export namespace ToolRegistry {
const custom = await state().then((x) => x.custom)
return [
InvalidTool,
BashTool,
EditTool,
WebFetchTool,
PatchTool,
BatchTool,
ReadTool,
GlobTool,
GrepTool,
ListTool,
PatchTool,
ReadTool,
EditTool,
BashTool,
TaskTool,
WriteTool,
WebFetchTool,
TodoWriteTool,
TodoReadTool,
TaskTool,
...custom,
]
}
Expand Down
11 changes: 8 additions & 3 deletions packages/tui/internal/components/chat/message.go
Original file line number Diff line number Diff line change
Expand Up @@ -466,6 +466,11 @@ func renderToolDetails(
return ""
}

// Hide batch tool only if it succeeded
if toolCall.Tool == "batch" && toolCall.State.Status != opencode.ToolPartStateStatusError {
return ""
}

if toolCall.State.Status == opencode.ToolPartStateStatusPending {
title := renderToolTitle(toolCall, width)
return renderContentBlock(app, title, width)
Expand Down Expand Up @@ -642,9 +647,9 @@ func renderToolDetails(
for _, item := range todos.([]any) {
todo := item.(map[string]any)
content := todo["content"]
if content == nil {
continue
}
if content == nil {
continue
}
switch todo["status"] {
case "completed":
body += fmt.Sprintf("- [x] %s\n", content)
Expand Down
Loading