summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--bun.lock3
-rw-r--r--package.json1
-rw-r--r--packages/opencode/specs/v2/keymappings.md (renamed from packages/opencode/specs/v2.md)6
-rw-r--r--packages/opencode/specs/v2/message-shape.md136
-rw-r--r--packages/opencode/src/cli/cmd/debug/agent.ts5
-rw-r--r--packages/opencode/src/server/routes/experimental.ts7
-rw-r--r--packages/opencode/src/session/prompt.ts21
-rw-r--r--packages/opencode/src/skill/index.ts24
-rw-r--r--packages/opencode/src/tool/bash.ts32
-rw-r--r--packages/opencode/src/tool/batch.ts183
-rw-r--r--packages/opencode/src/tool/batch.txt24
-rw-r--r--packages/opencode/src/tool/question.ts2
-rw-r--r--packages/opencode/src/tool/registry.ts215
-rw-r--r--packages/opencode/src/tool/skill.ts43
-rw-r--r--packages/opencode/src/tool/task.ts63
-rw-r--r--packages/opencode/src/tool/task.txt3
-rw-r--r--packages/opencode/src/tool/todo.ts2
-rw-r--r--packages/opencode/src/tool/tool.ts37
-rw-r--r--packages/opencode/src/tool/websearch.ts40
-rw-r--r--packages/opencode/test/tool/skill.test.ts23
-rw-r--r--packages/opencode/test/tool/task.test.ts19
-rw-r--r--packages/opencode/test/tool/tool-define.test.ts52
-rw-r--r--packages/sdk/js/src/v2/client.ts3
-rw-r--r--packages/sdk/js/src/v2/data.ts32
-rw-r--r--packages/sdk/js/src/v2/index.ts3
25 files changed, 467 insertions, 512 deletions
diff --git a/bun.lock b/bun.lock
index 1c6bcd471..897ca4fa9 100644
--- a/bun.lock
+++ b/bun.lock
@@ -9,6 +9,7 @@
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
+ "heap-snapshot-toolkit": "1.1.3",
"typescript": "catalog:",
},
"devDependencies": {
@@ -3257,6 +3258,8 @@
"he": ["[email protected]", "", { "bin": { "he": "bin/he" } }, "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="],
+ "heap-snapshot-toolkit": ["[email protected]", "", {}, "sha512-joThu2rEsDu8/l4arupRDI1qP4CZXNG+J6Wr348vnbLGSiBkwRdqZ6aOHl5BzEiC+Dc8OTbMlmWjD0lbXD5K2Q=="],
+
"hey-listen": ["[email protected]", "", {}, "sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q=="],
"hono": ["[email protected]", "", {}, "sha512-icXIITfw/07Q88nLSkB9aiUrd8rYzSweK681Kjo/TSggaGbOX4RRyxxm71v+3PC8C/j+4rlxGeoTRxQDkaJkUw=="],
diff --git a/package.json b/package.json
index 4ce36d17e..d4713f95d 100644
--- a/package.json
+++ b/package.json
@@ -91,6 +91,7 @@
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
+ "heap-snapshot-toolkit": "1.1.3",
"typescript": "catalog:"
},
"repository": {
diff --git a/packages/opencode/specs/v2.md b/packages/opencode/specs/v2/keymappings.md
index 66b4d2dea..5b23db795 100644
--- a/packages/opencode/specs/v2.md
+++ b/packages/opencode/specs/v2/keymappings.md
@@ -1,8 +1,4 @@
-# 2.0
-
-What we would change if we could
-
-## Keybindings vs. Keymappings
+# Keybindings vs. Keymappings
Make it `keymappings`, closer to neovim. Can be layered like `<leader>abc`. Commands don't define their binding, but have an id that a key can be mapped to like
diff --git a/packages/opencode/specs/v2/message-shape.md b/packages/opencode/specs/v2/message-shape.md
new file mode 100644
index 000000000..965498f19
--- /dev/null
+++ b/packages/opencode/specs/v2/message-shape.md
@@ -0,0 +1,136 @@
+# Message Shape
+
+Problem:
+
+- stored messages need enough data to replay and resume a session later
+- prompt hooks often just want to append a synthetic user/assistant message
+- today that means faking ids, timestamps, and request metadata
+
+## Option 1: Two Message Shapes
+
+Keep `User` / `Assistant` for stored history, but clean them up.
+
+```ts
+type User = {
+ role: "user"
+ time: { created: number }
+ request: {
+ agent: string
+ model: ModelRef
+ variant?: string
+ format?: OutputFormat
+ system?: string
+ tools?: Record<string, boolean>
+ }
+}
+
+type Assistant = {
+ role: "assistant"
+ run: { agent: string; model: ModelRef; path: { cwd: string; root: string } }
+ usage: { cost: number; tokens: Tokens }
+ result: { finish?: string; error?: Error; structured?: unknown; kind: "reply" | "summary" }
+}
+```
+
+Add a separate transient `PromptMessage` for prompt surgery.
+
+```ts
+type PromptMessage = {
+ role: "user" | "assistant"
+ parts: PromptPart[]
+}
+```
+
+Plugin hook example:
+
+```ts
+prompt.push({
+ role: "user",
+ parts: [{ type: "text", text: "Summarize the tool output above and continue." }],
+})
+```
+
+Tradeoff: prompt hooks get easy lightweight messages, but there are now two message shapes.
+
+## Option 2: Prompt Mutators
+
+Keep `User` / `Assistant` as the stored history model.
+
+Prompt hooks do not build messages directly. The runtime gives them prompt mutators.
+
+```ts
+type PromptEditor = {
+ append(input: { role: "user" | "assistant"; parts: PromptPart[] }): void
+ prepend(input: { role: "user" | "assistant"; parts: PromptPart[] }): void
+ appendTo(target: "last-user" | "last-assistant", parts: PromptPart[]): void
+ insertAfter(messageID: string, input: { role: "user" | "assistant"; parts: PromptPart[] }): void
+ insertBefore(messageID: string, input: { role: "user" | "assistant"; parts: PromptPart[] }): void
+}
+```
+
+Plugin hook examples:
+
+```ts
+prompt.append({
+ role: "user",
+ parts: [{ type: "text", text: "Summarize the tool output above and continue." }],
+})
+```
+
+```ts
+prompt.appendTo("last-user", [{ type: "text", text: BUILD_SWITCH }])
+```
+
+Tradeoff: avoids a second full message type and avoids fake ids/timestamps, but moves more magic into the hook API.
+
+## Option 3: Separate Turn State
+
+Move execution settings out of `User` and into a separate turn/request object.
+
+```ts
+type Turn = {
+ id: string
+ request: {
+ agent: string
+ model: ModelRef
+ variant?: string
+ format?: OutputFormat
+ system?: string
+ tools?: Record<string, boolean>
+ }
+}
+
+type User = {
+ role: "user"
+ turnID: string
+ time: { created: number }
+}
+
+type Assistant = {
+ role: "assistant"
+ turnID: string
+ usage: { cost: number; tokens: Tokens }
+ result: { finish?: string; error?: Error; structured?: unknown; kind: "reply" | "summary" }
+}
+```
+
+Examples:
+
+```ts
+const turn = {
+ request: {
+ agent: "build",
+ model: { providerID: "openai", modelID: "gpt-5" },
+ },
+}
+```
+
+```ts
+const msg = {
+ role: "user",
+ turnID: turn.id,
+ parts: [{ type: "text", text: "Summarize the tool output above and continue." }],
+}
+```
+
+Tradeoff: stored messages get much smaller and cleaner, but replay now has to join messages with turn state and prompt hooks still need a way to pick which turn they belong to.
diff --git a/packages/opencode/src/cli/cmd/debug/agent.ts b/packages/opencode/src/cli/cmd/debug/agent.ts
index 7f451e98c..458f92547 100644
--- a/packages/opencode/src/cli/cmd/debug/agent.ts
+++ b/packages/opencode/src/cli/cmd/debug/agent.ts
@@ -71,7 +71,10 @@ export const AgentCommand = cmd({
async function getAvailableTools(agent: Agent.Info) {
const model = agent.model ?? (await Provider.defaultModel())
- return ToolRegistry.tools(model, agent)
+ return ToolRegistry.tools({
+ ...model,
+ agent,
+ })
}
async function resolveTools(agent: Agent.Info, availableTools: Awaited<ReturnType<typeof getAvailableTools>>) {
diff --git a/packages/opencode/src/server/routes/experimental.ts b/packages/opencode/src/server/routes/experimental.ts
index c673393d0..763cdcf77 100644
--- a/packages/opencode/src/server/routes/experimental.ts
+++ b/packages/opencode/src/server/routes/experimental.ts
@@ -15,6 +15,7 @@ import { zodToJsonSchema } from "zod-to-json-schema"
import { errors } from "../error"
import { lazy } from "../../util/lazy"
import { WorkspaceRoutes } from "./workspace"
+import { Agent } from "@/agent/agent"
const ConsoleOrgOption = z.object({
accountID: z.string(),
@@ -181,7 +182,11 @@ export const ExperimentalRoutes = lazy(() =>
),
async (c) => {
const { provider, model } = c.req.valid("query")
- const tools = await ToolRegistry.tools({ providerID: ProviderID.make(provider), modelID: ModelID.make(model) })
+ const tools = await ToolRegistry.tools({
+ providerID: ProviderID.make(provider),
+ modelID: ModelID.make(model),
+ agent: await Agent.get(await Agent.defaultAgent()),
+ })
return c.json(
tools.map((t) => ({
id: t.id,
diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts
index b91dfded5..c29733999 100644
--- a/packages/opencode/src/session/prompt.ts
+++ b/packages/opencode/src/session/prompt.ts
@@ -11,7 +11,6 @@ import { Provider } from "../provider/provider"
import { ModelID, ProviderID } from "../provider/schema"
import { type Tool as AITool, tool, jsonSchema, type ToolExecutionOptions, asSchema } from "ai"
import { SessionCompaction } from "./compaction"
-import { Instance } from "../project/instance"
import { Bus } from "../bus"
import { ProviderTransform } from "../provider/transform"
import { SystemPrompt } from "./system"
@@ -24,7 +23,6 @@ import { ToolRegistry } from "../tool/registry"
import { Runner } from "@/effect/runner"
import { MCP } from "../mcp"
import { LSP } from "../lsp"
-import { ReadTool } from "../tool/read"
import { FileTime } from "../file/time"
import { Flag } from "../flag/flag"
import { ulid } from "ulid"
@@ -37,7 +35,6 @@ import { ConfigMarkdown } from "../config/markdown"
import { SessionSummary } from "./summary"
import { NamedError } from "@opencode-ai/util/error"
import { SessionProcessor } from "./processor"
-import { TaskTool } from "@/tool/task"
import { Tool } from "@/tool/tool"
import { Permission } from "@/permission"
import { SessionStatus } from "./status"
@@ -50,6 +47,7 @@ import { Process } from "@/util/process"
import { Cause, Effect, Exit, Layer, Option, Scope, ServiceMap } from "effect"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
+import { TaskTool } from "@/tool/task"
// @ts-ignore
globalThis.AI_SDK_LOG_WARNINGS = false
@@ -433,10 +431,11 @@ NOTE: At any point in time through this workflow you should feel free to ask the
),
})
- for (const item of yield* registry.tools(
- { modelID: ModelID.make(input.model.api.id), providerID: input.model.providerID },
- input.agent,
- )) {
+ for (const item of yield* registry.tools({
+ modelID: ModelID.make(input.model.api.id),
+ providerID: input.model.providerID,
+ agent: input.agent,
+ })) {
const schema = ProviderTransform.schema(input.model, z.toJSONSchema(item.parameters))
tools[item.id] = tool({
id: item.id as any,
@@ -560,7 +559,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
}) {
const { task, model, lastUser, sessionID, session, msgs } = input
const ctx = yield* InstanceState.context
- const taskTool = yield* Effect.promise(() => registry.named.task.init())
+ const taskTool = yield* registry.fromID(TaskTool.id)
const taskModel = task.model ? yield* getModel(task.model.providerID, task.model.modelID, sessionID) : model
const assistantMessage: MessageV2.Assistant = yield* sessions.updateMessage({
id: MessageID.ascending(),
@@ -583,7 +582,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
sessionID: assistantMessage.sessionID,
type: "tool",
callID: ulid(),
- tool: registry.named.task.id,
+ tool: TaskTool.id,
state: {
status: "running",
input: {
@@ -1113,7 +1112,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
text: `Called the Read tool with the following input: ${JSON.stringify(args)}`,
},
]
- const read = yield* Effect.promise(() => registry.named.read.init()).pipe(
+ const read = yield* registry.fromID("read").pipe(
Effect.flatMap((t) =>
provider.getModel(info.model.providerID, info.model.modelID).pipe(
Effect.flatMap((mdl) =>
@@ -1177,7 +1176,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
if (part.mime === "application/x-directory") {
const args = { filePath: filepath }
- const result = yield* Effect.promise(() => registry.named.read.init()).pipe(
+ const result = yield* registry.fromID("read").pipe(
Effect.flatMap((t) =>
Effect.promise(() =>
t.execute(args, {
diff --git a/packages/opencode/src/skill/index.ts b/packages/opencode/src/skill/index.ts
index a2ac3d351..cde36dd52 100644
--- a/packages/opencode/src/skill/index.ts
+++ b/packages/opencode/src/skill/index.ts
@@ -239,22 +239,28 @@ export namespace Skill {
export function fmt(list: Info[], opts: { verbose: boolean }) {
if (list.length === 0) return "No skills are currently available."
-
if (opts.verbose) {
return [
"<available_skills>",
- ...list.flatMap((skill) => [
- " <skill>",
- ` <name>${skill.name}</name>`,
- ` <description>${skill.description}</description>`,
- ` <location>${pathToFileURL(skill.location).href}</location>`,
- " </skill>",
- ]),
+ ...list
+ .sort((a, b) => a.name.localeCompare(b.name))
+ .flatMap((skill) => [
+ " <skill>",
+ ` <name>${skill.name}</name>`,
+ ` <description>${skill.description}</description>`,
+ ` <location>${pathToFileURL(skill.location).href}</location>`,
+ " </skill>",
+ ]),
"</available_skills>",
].join("\n")
}
- return ["## Available Skills", ...list.map((skill) => `- **${skill.name}**: ${skill.description}`)].join("\n")
+ return [
+ "## Available Skills",
+ ...list
+ .toSorted((a, b) => a.name.localeCompare(b.name))
+ .map((skill) => `- **${skill.name}**: ${skill.description}`),
+ ].join("\n")
}
const { runPromise } = makeRuntime(Service, defaultLayer)
diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts
index e50f09cc3..365fda329 100644
--- a/packages/opencode/src/tool/bash.ts
+++ b/packages/opencode/src/tool/bash.ts
@@ -50,6 +50,22 @@ const FILES = new Set([
const FLAGS = new Set(["-destination", "-literalpath", "-path"])
const SWITCHES = new Set(["-confirm", "-debug", "-force", "-nonewline", "-recurse", "-verbose", "-whatif"])
+const Parameters = z.object({
+ command: z.string().describe("The command to execute"),
+ timeout: z.number().describe("Optional timeout in milliseconds").optional(),
+ workdir: z
+ .string()
+ .describe(
+ `The working directory to run the command in. Defaults to the current directory. Use this instead of 'cd' commands.`,
+ )
+ .optional(),
+ description: z
+ .string()
+ .describe(
+ "Clear, concise description of what this command does in 5-10 words. Examples:\nInput: ls\nOutput: Lists files in current directory\n\nInput: git status\nOutput: Shows working tree status\n\nInput: npm install\nOutput: Installs package dependencies\n\nInput: mkdir foo\nOutput: Creates directory 'foo'",
+ ),
+})
+
type Part = {
type: string
text: string
@@ -452,21 +468,7 @@ export const BashTool = Tool.define("bash", async () => {
.replaceAll("${chaining}", chain)
.replaceAll("${maxLines}", String(Truncate.MAX_LINES))
.replaceAll("${maxBytes}", String(Truncate.MAX_BYTES)),
- parameters: z.object({
- command: z.string().describe("The command to execute"),
- timeout: z.number().describe("Optional timeout in milliseconds").optional(),
- workdir: z
- .string()
- .describe(
- `The working directory to run the command in. Defaults to ${Instance.directory}. Use this instead of 'cd' commands.`,
- )
- .optional(),
- description: z
- .string()
- .describe(
- "Clear, concise description of what this command does in 5-10 words. Examples:\nInput: ls\nOutput: Lists files in current directory\n\nInput: git status\nOutput: Shows working tree status\n\nInput: npm install\nOutput: Installs package dependencies\n\nInput: mkdir foo\nOutput: Creates directory 'foo'",
- ),
- }),
+ parameters: Parameters,
async execute(params, ctx) {
const cwd = params.workdir ? await resolvePath(params.workdir, Instance.directory, shell) : Instance.directory
if (params.timeout !== undefined && params.timeout < 0) {
diff --git a/packages/opencode/src/tool/batch.ts b/packages/opencode/src/tool/batch.ts
deleted file mode 100644
index c79a530f7..000000000
--- a/packages/opencode/src/tool/batch.ts
+++ /dev/null
@@ -1,183 +0,0 @@
-import z from "zod"
-import { Tool } from "./tool"
-import { ProviderID, ModelID } from "../provider/schema"
-import { errorMessage } from "../util/error"
-import DESCRIPTION from "./batch.txt"
-
-const DISALLOWED = new Set(["batch"])
-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")
- .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 { Session } = await import("../session")
- const { PartID } = await import("../session/schema")
-
- const toolCalls = params.tool_calls.slice(0, 25)
- const discardedCalls = params.tool_calls.slice(25)
-
- const { ToolRegistry } = await import("./registry")
- const availableTools = await ToolRegistry.tools({ modelID: ModelID.make(""), providerID: ProviderID.make("") })
- const toolMap = new Map(availableTools.map((t) => [t.id, t]))
-
- const executeCall = async (call: (typeof toolCalls)[0]) => {
- const callStartTime = Date.now()
- const partID = PartID.ascending()
-
- try {
- if (DISALLOWED.has(call.tool)) {
- throw new Error(
- `Tool '${call.tool}' is not allowed in batch. Disallowed tools: ${Array.from(DISALLOWED).join(", ")}`,
- )
- }
-
- 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 in registry. External tools (MCP, environment) cannot be batched - call them directly. Available tools: ${availableToolsList.join(", ")}`,
- )
- }
- const validatedParams = tool.parameters.parse(call.parameters)
-
- 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,
- },
- },
- })
-
- const result = await tool.execute(validatedParams, { ...ctx, callID: partID })
- const attachments = result.attachments?.map((attachment) => ({
- ...attachment,
- id: PartID.ascending(),
- sessionID: ctx.sessionID,
- messageID: ctx.messageID,
- }))
-
- 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,
- attachments,
- time: {
- start: callStartTime,
- end: Date.now(),
- },
- },
- })
-
- return { success: true as const, tool: call.tool, result }
- } catch (error) {
- 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: errorMessage(error),
- time: {
- start: callStartTime,
- end: Date.now(),
- },
- },
- })
-
- return { success: false as const, tool: call.tool, error }
- }
- }
-
- const results = await Promise.all(toolCalls.map((call) => executeCall(call)))
-
- // Add discarded calls as errors
- const now = Date.now()
- for (const call of discardedCalls) {
- const partID = PartID.ascending()
- 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: "Maximum of 25 tools allowed in batch",
- time: { start: now, end: now },
- },
- })
- results.push({
- success: false as const,
- tool: call.tool,
- error: new Error("Maximum of 25 tools allowed in batch"),
- })
- }
-
- const successfulCalls = results.filter((r) => r.success).length
- const failedCalls = results.length - successfulCalls
-
- const outputMessage =
- failedCalls > 0
- ? `Executed ${successfulCalls}/${results.length} tools successfully. ${failedCalls} failed.`
- : `All ${successfulCalls} tools executed successfully.\n\nKeep using the batch tool for optimal performance in your next response!`
-
- return {
- title: `Batch execution (${successfulCalls}/${results.length} successful)`,
- output: outputMessage,
- attachments: results.filter((result) => result.success).flatMap((r) => r.result.attachments ?? []),
- metadata: {
- totalCalls: results.length,
- successful: successfulCalls,
- failed: failedCalls,
- tools: params.tool_calls.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
deleted file mode 100644
index 968a6c3f0..000000000
--- a/packages/opencode/src/tool/batch.txt
+++ /dev/null
@@ -1,24 +0,0 @@
-Executes multiple independent tool calls concurrently to reduce latency.
-
-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"}}]
-
-Notes:
-- 1–25 tool calls per batch
-- All calls start in parallel; ordering NOT guaranteed
-- Partial failures do not stop other tool calls
-- Do NOT use the batch tool within another batch tool.
-
-Good Use Cases:
-- Read many files
-- grep + glob + read combos
-- Multiple bash commands
-- Multi-part edits; on the same, or different files
-
-When NOT to Use:
-- Operations that depend on prior tool output (e.g. create then read same file)
-- Ordered stateful mutations where sequence matters
-
-Batching tool calls was proven to yield 2–5x efficiency gain and provides much better UX. \ No newline at end of file
diff --git a/packages/opencode/src/tool/question.ts b/packages/opencode/src/tool/question.ts
index dd9968888..23c9b35c8 100644
--- a/packages/opencode/src/tool/question.ts
+++ b/packages/opencode/src/tool/question.ts
@@ -41,6 +41,6 @@ export const QuestionTool = Tool.defineEffect<typeof parameters, Metadata, Quest
},
}
},
- } satisfies Tool.Def<typeof parameters, Metadata>
+ }
}),
)
diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts
index 9c045338e..72911051e 100644
--- a/packages/opencode/src/tool/registry.ts
+++ b/packages/opencode/src/tool/registry.ts
@@ -4,18 +4,15 @@ import { BashTool } from "./bash"
import { EditTool } from "./edit"
import { GlobTool } from "./glob"
import { GrepTool } from "./grep"
-import { BatchTool } from "./batch"
import { ReadTool } from "./read"
-import { TaskTool } from "./task"
+import { TaskDescription, TaskTool } from "./task"
import { TodoWriteTool } from "./todo"
import { WebFetchTool } from "./webfetch"
import { WriteTool } from "./write"
import { InvalidTool } from "./invalid"
-import { SkillTool } from "./skill"
-import type { Agent } from "../agent/agent"
+import { SkillDescription, SkillTool } from "./skill"
import { Tool } from "./tool"
import { Config } from "../config/config"
-import path from "path"
import { type ToolContext as PluginToolContext, type ToolDefinition } from "@opencode-ai/plugin"
import z from "zod"
import { Plugin } from "../plugin"
@@ -28,6 +25,7 @@ import { LspTool } from "./lsp"
import { Truncate } from "./truncate"
import { ApplyPatchTool } from "./apply_patch"
import { Glob } from "../util/glob"
+import path from "path"
import { pathToFileURL } from "url"
import { Effect, Layer, ServiceMap } from "effect"
import { InstanceState } from "@/effect/instance-state"
@@ -39,24 +37,25 @@ import { LSP } from "../lsp"
import { FileTime } from "../file/time"
import { Instruction } from "../session/instruction"
import { AppFileSystem } from "../filesystem"
+import { Agent } from "../agent/agent"
export namespace ToolRegistry {
const log = Log.create({ service: "tool.registry" })
type State = {
- custom: Tool.Info[]
+ custom: Tool.Def[]
+ builtin: Tool.Def[]
}
export interface Interface {
readonly ids: () => Effect.Effect<string[]>
- readonly named: {
- task: Tool.Info
- read: Tool.Info
- }
- readonly tools: (
- model: { providerID: ProviderID; modelID: ModelID },
- agent?: Agent.Info,
- ) => Effect.Effect<(Tool.Def & { id: string })[]>
+ readonly all: () => Effect.Effect<Tool.Def[]>
+ readonly tools: (model: {
+ providerID: ProviderID
+ modelID: ModelID
+ agent: Agent.Info
+ }) => Effect.Effect<Tool.Def[]>
+ readonly fromID: (id: string) => Effect.Effect<Tool.Def>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/ToolRegistry") {}
@@ -79,33 +78,34 @@ export namespace ToolRegistry {
const plugin = yield* Plugin.Service
const build = <T extends Tool.Info>(tool: T | Effect.Effect<T, never, any>) =>
- Effect.isEffect(tool) ? tool : Effect.succeed(tool)
+ Effect.isEffect(tool) ? tool.pipe(Effect.flatMap(Tool.init)) : Tool.init(tool)
const state = yield* InstanceState.make<State>(
Effect.fn("ToolRegistry.state")(function* (ctx) {
- const custom: Tool.Info[] = []
+ const custom: Tool.Def[] = []
- function fromPlugin(id: string, def: ToolDefinition): Tool.Info {
+ function fromPlugin(id: string, def: ToolDefinition): Tool.Def {
return {
id,
- init: async (initCtx) => ({
- parameters: z.object(def.args),
- description: def.description,
- execute: async (args, toolCtx) => {
- const pluginCtx = {
- ...toolCtx,
- directory: ctx.directory,
- worktree: ctx.worktree,
- } as unknown as PluginToolContext
- const result = await def.execute(args as any, pluginCtx)
- const out = await Truncate.output(result, {}, initCtx?.agent)
- return {
- title: "",
- output: out.truncated ? out.content : result,
- metadata: { truncated: out.truncated, outputPath: out.truncated ? out.outputPath : undefined },
- }
- },
- }),
+ parameters: z.object(def.args),
+ description: def.description,
+ execute: async (args, toolCtx) => {
+ const pluginCtx = {
+ ...toolCtx,
+ directory: ctx.directory,
+ worktree: ctx.worktree,
+ } as unknown as PluginToolContext
+ const result = await def.execute(args as any, pluginCtx)
+ const out = await Truncate.output(result, {}, await Agent.get(toolCtx.agent))
+ return {
+ title: "",
+ output: out.truncated ? out.content : result,
+ metadata: {
+ truncated: out.truncated,
+ outputPath: out.truncated ? out.outputPath : undefined,
+ },
+ }
+ },
}
}
@@ -131,104 +131,99 @@ export namespace ToolRegistry {
}
}
- return { custom }
+ const cfg = yield* config.get()
+ const question =
+ ["app", "cli", "desktop"].includes(Flag.OPENCODE_CLIENT) || Flag.OPENCODE_ENABLE_QUESTION_TOOL
+
+ return {
+ custom,
+ builtin: yield* Effect.forEach(
+ [
+ InvalidTool,
+ BashTool,
+ ReadTool,
+ GlobTool,
+ GrepTool,
+ EditTool,
+ WriteTool,
+ TaskTool,
+ WebFetchTool,
+ TodoWriteTool,
+ WebSearchTool,
+ CodeSearchTool,
+ SkillTool,
+ ApplyPatchTool,
+ ...(question ? [QuestionTool] : []),
+ ...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [LspTool] : []),
+ ...(Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE && Flag.OPENCODE_CLIENT === "cli" ? [PlanExitTool] : []),
+ ],
+ build,
+ { concurrency: "unbounded" },
+ ),
+ }
}),
)
- const invalid = yield* build(InvalidTool)
- const ask = yield* build(QuestionTool)
- const bash = yield* build(BashTool)
- const read = yield* build(ReadTool)
- const glob = yield* build(GlobTool)
- const grep = yield* build(GrepTool)
- const edit = yield* build(EditTool)
- const write = yield* build(WriteTool)
- const task = yield* build(TaskTool)
- const fetch = yield* build(WebFetchTool)
- const todo = yield* build(TodoWriteTool)
- const search = yield* build(WebSearchTool)
- const code = yield* build(CodeSearchTool)
- const skill = yield* build(SkillTool)
- const patch = yield* build(ApplyPatchTool)
- const lsp = yield* build(LspTool)
- const batch = yield* build(BatchTool)
- const plan = yield* build(PlanExitTool)
-
- const all = Effect.fn("ToolRegistry.all")(function* (custom: Tool.Info[]) {
- const cfg = yield* config.get()
- const question = ["app", "cli", "desktop"].includes(Flag.OPENCODE_CLIENT) || Flag.OPENCODE_ENABLE_QUESTION_TOOL
+ const all: Interface["all"] = Effect.fn("ToolRegistry.all")(function* () {
+ const s = yield* InstanceState.get(state)
+ return [...s.builtin, ...s.custom] as Tool.Def[]
+ })
- return [
- invalid,
- ...(question ? [ask] : []),
- bash,
- read,
- glob,
- grep,
- edit,
- write,
- task,
- fetch,
- todo,
- search,
- code,
- skill,
- patch,
- ...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [lsp] : []),
- ...(cfg.experimental?.batch_tool === true ? [batch] : []),
- ...(Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE && Flag.OPENCODE_CLIENT === "cli" ? [plan] : []),
- ...custom,
- ]
+ const fromID: Interface["fromID"] = Effect.fn("ToolRegistry.fromID")(function* (id: string) {
+ const tools = yield* all()
+ const match = tools.find((tool) => tool.id === id)
+ if (!match) return yield* Effect.die(`Tool not found: ${id}`)
+ return match
})
- const ids = Effect.fn("ToolRegistry.ids")(function* () {
- const s = yield* InstanceState.get(state)
- const tools = yield* all(s.custom)
- return tools.map((t) => t.id)
+ const ids: Interface["ids"] = Effect.fn("ToolRegistry.ids")(function* () {
+ return (yield* all()).map((tool) => tool.id)
})
- const tools = Effect.fn("ToolRegistry.tools")(function* (
- model: { providerID: ProviderID; modelID: ModelID },
- agent?: Agent.Info,
- ) {
- const s = yield* InstanceState.get(state)
- const allTools = yield* all(s.custom)
- const filtered = allTools.filter((tool) => {
- if (tool.id === "codesearch" || tool.id === "websearch") {
- return model.providerID === ProviderID.opencode || Flag.OPENCODE_ENABLE_EXA
+ const tools: Interface["tools"] = Effect.fn("ToolRegistry.tools")(function* (input) {
+ const filtered = (yield* all()).filter((tool) => {
+ if (tool.id === CodeSearchTool.id || tool.id === WebSearchTool.id) {
+ return input.providerID === ProviderID.opencode || Flag.OPENCODE_ENABLE_EXA
}
const usePatch =
!!Env.get("OPENCODE_E2E_LLM_URL") ||
- (model.modelID.includes("gpt-") && !model.modelID.includes("oss") && !model.modelID.includes("gpt-4"))
- if (tool.id === "apply_patch") return usePatch
- if (tool.id === "edit" || tool.id === "write") return !usePatch
+ (input.modelID.includes("gpt-") && !input.modelID.includes("oss") && !input.modelID.includes("gpt-4"))
+ if (tool.id === ApplyPatchTool.id) return usePatch
+ if (tool.id === EditTool.id || tool.id === WriteTool.id) return !usePatch
return true
})
+
return yield* Effect.forEach(
filtered,
- Effect.fnUntraced(function* (tool: Tool.Info) {
+ Effect.fnUntraced(function* (tool: Tool.Def) {
using _ = log.time(tool.id)
- const next = yield* Effect.promise(() => tool.init({ agent }))
const output = {
- description: next.description,
- parameters: next.parameters,
+ description: tool.description,
+ parameters: tool.parameters,
}
yield* plugin.trigger("tool.definition", { toolID: tool.id }, output)
return {
id: tool.id,
- description: output.description,
+ description: [
+ output.description,
+ // TODO: remove this hack
+ tool.id === TaskTool.id ? yield* TaskDescription(input.agent) : undefined,
+ tool.id === SkillTool.id ? yield* SkillDescription(input.agent) : undefined,
+ ]
+ .filter(Boolean)
+ .join("\n"),
parameters: output.parameters,
- execute: next.execute,
- formatValidationError: next.formatValidationError,
+ execute: tool.execute,
+ formatValidationError: tool.formatValidationError,
}
}),
{ concurrency: "unbounded" },
)
})
- return Service.of({ ids, named: { task, read }, tools })
+ return Service.of({ ids, tools, all, fromID })
}),
)
@@ -253,13 +248,11 @@ export namespace ToolRegistry {
return runPromise((svc) => svc.ids())
}
- export async function tools(
- model: {
- providerID: ProviderID
- modelID: ModelID
- },
- agent?: Agent.Info,
- ): Promise<(Tool.Def & { id: string })[]> {
- return runPromise((svc) => svc.tools(model, agent))
+ export async function tools(input: {
+ providerID: ProviderID
+ modelID: ModelID
+ agent: Agent.Info
+ }): Promise<(Tool.Def & { id: string })[]> {
+ return runPromise((svc) => svc.tools(input))
}
}
diff --git a/packages/opencode/src/tool/skill.ts b/packages/opencode/src/tool/skill.ts
index 17016b06f..276f3931d 100644
--- a/packages/opencode/src/tool/skill.ts
+++ b/packages/opencode/src/tool/skill.ts
@@ -1,3 +1,4 @@
+import { Effect } from "effect"
import path from "path"
import { pathToFileURL } from "url"
import z from "zod"
@@ -6,8 +7,12 @@ import { Skill } from "../skill"
import { Ripgrep } from "../file/ripgrep"
import { iife } from "@/util/iife"
-export const SkillTool = Tool.define("skill", async (ctx) => {
- const list = await Skill.available(ctx?.agent)
+const Parameters = z.object({
+ name: z.string().describe("The name of the skill from available_skills"),
+})
+
+export const SkillTool = Tool.define("skill", async () => {
+ const list = await Skill.available()
const description =
list.length === 0
@@ -27,20 +32,10 @@ export const SkillTool = Tool.define("skill", async (ctx) => {
Skill.fmt(list, { verbose: false }),
].join("\n")
- const examples = list
- .map((skill) => `'${skill.name}'`)
- .slice(0, 3)
- .join(", ")
- const hint = examples.length > 0 ? ` (e.g., ${examples}, ...)` : ""
-
- const parameters = z.object({
- name: z.string().describe(`The name of the skill from available_skills${hint}`),
- })
-
return {
description,
- parameters,
- async execute(params: z.infer<typeof parameters>, ctx) {
+ parameters: Parameters,
+ async execute(params: z.infer<typeof Parameters>, ctx) {
const skill = await Skill.get(params.name)
if (!skill) {
@@ -103,3 +98,23 @@ export const SkillTool = Tool.define("skill", async (ctx) => {
},
}
})
+
+export const SkillDescription: Tool.DynamicDescription = (agent) =>
+ Effect.gen(function* () {
+ const list = yield* Effect.promise(() => Skill.available(agent))
+ if (list.length === 0) return "No skills are currently available."
+ return [
+ "Load a specialized skill that provides domain-specific instructions and workflows.",
+ "",
+ "When you recognize that a task matches one of the available skills listed below, use this tool to load the full skill instructions.",
+ "",
+ "The skill will inject detailed instructions, workflows, and access to bundled resources (scripts, references, templates) into the conversation context.",
+ "",
+ 'Tool output includes a `<skill_content name="...">` block with the loaded content.',
+ "",
+ "The following skills provide specialized sets of instructions for particular tasks",
+ "Invoke this tool to load a skill when a task matches one of the available skills listed below:",
+ "",
+ Skill.fmt(list, { verbose: false }),
+ ].join("\n")
+ })
diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts
index af130a70d..07e779f5b 100644
--- a/packages/opencode/src/tool/task.ts
+++ b/packages/opencode/src/tool/task.ts
@@ -4,47 +4,37 @@ import z from "zod"
import { Session } from "../session"
import { SessionID, MessageID } from "../session/schema"
import { MessageV2 } from "../session/message-v2"
-import { Identifier } from "../id/id"
import { Agent } from "../agent/agent"
import { SessionPrompt } from "../session/prompt"
import { iife } from "@/util/iife"
import { defer } from "@/util/defer"
import { Config } from "../config/config"
import { Permission } from "@/permission"
+import { Effect } from "effect"
-const parameters = z.object({
- description: z.string().describe("A short (3-5 words) description of the task"),
- prompt: z.string().describe("The task for the agent to perform"),
- subagent_type: z.string().describe("The type of specialized agent to use for this task"),
- task_id: z
- .string()
- .describe(
- "This should only be set if you mean to resume a previous task (you can pass a prior task_id and the task will continue the same subagent session as before instead of creating a fresh one)",
- )
- .optional(),
- command: z.string().describe("The command that triggered this task").optional(),
-})
-
-export const TaskTool = Tool.define("task", async (ctx) => {
+export const TaskTool = Tool.define("task", async () => {
const agents = await Agent.list().then((x) => x.filter((a) => a.mode !== "primary"))
+ const list = agents.toSorted((a, b) => a.name.localeCompare(b.name))
+ const agentList = list
+ .map((a) => `- ${a.name}: ${a.description ?? "This subagent should only be called manually by the user."}`)
+ .join("\n")
+ const description = [`Available agent types and the tools they have access to:`, agentList].join("\n")
- // Filter agents by permissions if agent provided
- const caller = ctx?.agent
- const accessibleAgents = caller
- ? agents.filter((a) => Permission.evaluate("task", a.name, caller.permission).action !== "deny")
- : agents
- const list = accessibleAgents.toSorted((a, b) => a.name.localeCompare(b.name))
-
- const description = DESCRIPTION.replace(
- "{agents}",
- list
- .map((a) => `- ${a.name}: ${a.description ?? "This subagent should only be called manually by the user."}`)
- .join("\n"),
- )
return {
description,
- parameters,
- async execute(params: z.infer<typeof parameters>, ctx) {
+ parameters: z.object({
+ description: z.string().describe("A short (3-5 words) description of the task"),
+ prompt: z.string().describe("The task for the agent to perform"),
+ subagent_type: z.string().describe("The type of specialized agent to use for this task"),
+ task_id: z
+ .string()
+ .describe(
+ "This should only be set if you mean to resume a previous task (you can pass a prior task_id and the task will continue the same subagent session as before instead of creating a fresh one)",
+ )
+ .optional(),
+ command: z.string().describe("The command that triggered this task").optional(),
+ }),
+ async execute(params, ctx) {
const config = await Config.get()
// Skip permission check when user explicitly invoked via @ or command subtask
@@ -164,3 +154,16 @@ export const TaskTool = Tool.define("task", async (ctx) => {
},
}
})
+
+export const TaskDescription: Tool.DynamicDescription = (agent) =>
+ Effect.gen(function* () {
+ const agents = yield* Effect.promise(() => Agent.list().then((x) => x.filter((a) => a.mode !== "primary")))
+ const accessibleAgents = agents.filter(
+ (a) => Permission.evaluate("task", a.name, agent.permission).action !== "deny",
+ )
+ const list = accessibleAgents.toSorted((a, b) => a.name.localeCompare(b.name))
+ const description = list
+ .map((a) => `- ${a.name}: ${a.description ?? "This subagent should only be called manually by the user."}`)
+ .join("\n")
+ return [`Available agent types and the tools they have access to:`, description].join("\n")
+ })
diff --git a/packages/opencode/src/tool/task.txt b/packages/opencode/src/tool/task.txt
index 585cce8f9..fba8470d1 100644
--- a/packages/opencode/src/tool/task.txt
+++ b/packages/opencode/src/tool/task.txt
@@ -1,8 +1,5 @@
Launch a new agent to handle complex, multistep tasks autonomously.
-Available agent types and the tools they have access to:
-{agents}
-
When using the Task tool, you must specify a subagent_type parameter to select which agent type to use.
When to use the Task tool:
diff --git a/packages/opencode/src/tool/todo.ts b/packages/opencode/src/tool/todo.ts
index d10e84931..92318164c 100644
--- a/packages/opencode/src/tool/todo.ts
+++ b/packages/opencode/src/tool/todo.ts
@@ -43,6 +43,6 @@ export const TodoWriteTool = Tool.defineEffect<typeof parameters, Metadata, Todo
},
}
},
- } satisfies Tool.Def<typeof parameters, Metadata>
+ } satisfies Tool.DefWithoutID<typeof parameters, Metadata>
}),
)
diff --git a/packages/opencode/src/tool/tool.ts b/packages/opencode/src/tool/tool.ts
index a107dad7e..6d129f427 100644
--- a/packages/opencode/src/tool/tool.ts
+++ b/packages/opencode/src/tool/tool.ts
@@ -1,19 +1,18 @@
import z from "zod"
import { Effect } from "effect"
import type { MessageV2 } from "../session/message-v2"
-import type { Agent } from "../agent/agent"
import type { Permission } from "../permission"
import type { SessionID, MessageID } from "../session/schema"
import { Truncate } from "./truncate"
+import { Agent } from "@/agent/agent"
export namespace Tool {
interface Metadata {
[key: string]: any
}
- export interface InitContext {
- agent?: Agent.Info
- }
+ // TODO: remove this hack
+ export type DynamicDescription = (agent: Agent.Info) => Effect.Effect<string>
export type Context<M extends Metadata = Metadata> = {
sessionID: SessionID
@@ -26,7 +25,9 @@ export namespace Tool {
metadata(input: { title?: string; metadata?: M }): void
ask(input: Omit<Permission.Request, "id" | "sessionID" | "tool">): Promise<void>
}
+
export interface Def<Parameters extends z.ZodType = z.ZodType, M extends Metadata = Metadata> {
+ id: string
description: string
parameters: Parameters
execute(
@@ -40,10 +41,14 @@ export namespace Tool {
}>
formatValidationError?(error: z.ZodError): string
}
+ export type DefWithoutID<Parameters extends z.ZodType = z.ZodType, M extends Metadata = Metadata> = Omit<
+ Def<Parameters, M>,
+ "id"
+ >
export interface Info<Parameters extends z.ZodType = z.ZodType, M extends Metadata = Metadata> {
id: string
- init: (ctx?: InitContext) => Promise<Def<Parameters, M>>
+ init: () => Promise<DefWithoutID<Parameters, M>>
}
export type InferParameters<T> =
@@ -57,10 +62,10 @@ export namespace Tool {
function wrap<Parameters extends z.ZodType, Result extends Metadata>(
id: string,
- init: ((ctx?: InitContext) => Promise<Def<Parameters, Result>>) | Def<Parameters, Result>,
+ init: (() => Promise<DefWithoutID<Parameters, Result>>) | DefWithoutID<Parameters, Result>,
) {
- return async (initCtx?: InitContext) => {
- const toolInfo = init instanceof Function ? await init(initCtx) : { ...init }
+ return async () => {
+ const toolInfo = init instanceof Function ? await init() : { ...init }
const execute = toolInfo.execute
toolInfo.execute = async (args, ctx) => {
try {
@@ -78,7 +83,7 @@ export namespace Tool {
if (result.metadata.truncated !== undefined) {
return result
}
- const truncated = await Truncate.output(result.output, {}, initCtx?.agent)
+ const truncated = await Truncate.output(result.output, {}, await Agent.get(ctx.agent))
return {
...result,
output: truncated.content,
@@ -95,7 +100,7 @@ export namespace Tool {
export function define<Parameters extends z.ZodType, Result extends Metadata>(
id: string,
- init: ((ctx?: InitContext) => Promise<Def<Parameters, Result>>) | Def<Parameters, Result>,
+ init: (() => Promise<DefWithoutID<Parameters, Result>>) | DefWithoutID<Parameters, Result>,
): Info<Parameters, Result> {
return {
id,
@@ -105,8 +110,18 @@ export namespace Tool {
export function defineEffect<Parameters extends z.ZodType, Result extends Metadata, R>(
id: string,
- init: Effect.Effect<((ctx?: InitContext) => Promise<Def<Parameters, Result>>) | Def<Parameters, Result>, never, R>,
+ init: Effect.Effect<(() => Promise<DefWithoutID<Parameters, Result>>) | DefWithoutID<Parameters, Result>, never, R>,
): Effect.Effect<Info<Parameters, Result>, never, R> {
return Effect.map(init, (next) => ({ id, init: wrap(id, next) }))
}
+
+ export function init(info: Info): Effect.Effect<Def, never, any> {
+ return Effect.gen(function* () {
+ const init = yield* Effect.promise(() => info.init())
+ return {
+ ...init,
+ id: info.id,
+ }
+ })
+ }
}
diff --git a/packages/opencode/src/tool/websearch.ts b/packages/opencode/src/tool/websearch.ts
index bf16428df..c0f1c8d10 100644
--- a/packages/opencode/src/tool/websearch.ts
+++ b/packages/opencode/src/tool/websearch.ts
@@ -11,6 +11,25 @@ const API_CONFIG = {
DEFAULT_NUM_RESULTS: 8,
} as const
+const Parameters = z.object({
+ query: z.string().describe("Websearch query"),
+ numResults: z.number().optional().describe("Number of search results to return (default: 8)"),
+ livecrawl: z
+ .enum(["fallback", "preferred"])
+ .optional()
+ .describe(
+ "Live crawl mode - 'fallback': use live crawling as backup if cached content unavailable, 'preferred': prioritize live crawling (default: 'fallback')",
+ ),
+ type: z
+ .enum(["auto", "fast", "deep"])
+ .optional()
+ .describe("Search type - 'auto': balanced search (default), 'fast': quick results, 'deep': comprehensive search"),
+ contextMaxCharacters: z
+ .number()
+ .optional()
+ .describe("Maximum characters for context string optimized for LLMs (default: 10000)"),
+})
+
interface McpSearchRequest {
jsonrpc: string
id: number
@@ -42,26 +61,7 @@ export const WebSearchTool = Tool.define("websearch", async () => {
get description() {
return DESCRIPTION.replace("{{year}}", new Date().getFullYear().toString())
},
- parameters: z.object({
- query: z.string().describe("Websearch query"),
- numResults: z.number().optional().describe("Number of search results to return (default: 8)"),
- livecrawl: z
- .enum(["fallback", "preferred"])
- .optional()
- .describe(
- "Live crawl mode - 'fallback': use live crawling as backup if cached content unavailable, 'preferred': prioritize live crawling (default: 'fallback')",
- ),
- type: z
- .enum(["auto", "fast", "deep"])
- .optional()
- .describe(
- "Search type - 'auto': balanced search (default), 'fast': quick results, 'deep': comprehensive search",
- ),
- contextMaxCharacters: z
- .number()
- .optional()
- .describe("Maximum characters for context string optimized for LLMs (default: 10000)"),
- }),
+ parameters: Parameters,
async execute(params, ctx) {
await ctx.ask({
permission: "websearch",
diff --git a/packages/opencode/test/tool/skill.test.ts b/packages/opencode/test/tool/skill.test.ts
index ffae223f9..e6269a4f3 100644
--- a/packages/opencode/test/tool/skill.test.ts
+++ b/packages/opencode/test/tool/skill.test.ts
@@ -1,10 +1,11 @@
+import { Effect } from "effect"
import { afterEach, describe, expect, test } from "bun:test"
import path from "path"
import { pathToFileURL } from "url"
import type { Permission } from "../../src/permission"
import type { Tool } from "../../src/tool/tool"
import { Instance } from "../../src/project/instance"
-import { SkillTool } from "../../src/tool/skill"
+import { SkillTool, SkillDescription } from "../../src/tool/skill"
import { tmpdir } from "../fixture/fixture"
import { SessionID, MessageID } from "../../src/session/schema"
@@ -48,9 +49,10 @@ description: Skill for tool tests.
await Instance.provide({
directory: tmp.path,
fn: async () => {
- const tool = await SkillTool.init()
- const skillPath = path.join(tmp.path, ".opencode", "skill", "tool-skill", "SKILL.md")
- expect(tool.description).toContain(`**tool-skill**: Skill for tool tests.`)
+ const desc = await Effect.runPromise(
+ SkillDescription({ name: "build", mode: "primary" as const, permission: [], options: {} }),
+ )
+ expect(desc).toContain(`**tool-skill**: Skill for tool tests.`)
},
})
} finally {
@@ -89,14 +91,15 @@ description: ${description}
await Instance.provide({
directory: tmp.path,
fn: async () => {
- const first = await SkillTool.init()
- const second = await SkillTool.init()
+ const agent = { name: "build", mode: "primary" as const, permission: [], options: {} }
+ const first = await Effect.runPromise(SkillDescription(agent))
+ const second = await Effect.runPromise(SkillDescription(agent))
- expect(first.description).toBe(second.description)
+ expect(first).toBe(second)
- const alpha = first.description.indexOf("**alpha-skill**: Alpha skill.")
- const middle = first.description.indexOf("**middle-skill**: Middle skill.")
- const zeta = first.description.indexOf("**zeta-skill**: Zeta skill.")
+ const alpha = first.indexOf("**alpha-skill**: Alpha skill.")
+ const middle = first.indexOf("**middle-skill**: Middle skill.")
+ const zeta = first.indexOf("**zeta-skill**: Zeta skill.")
expect(alpha).toBeGreaterThan(-1)
expect(middle).toBeGreaterThan(alpha)
diff --git a/packages/opencode/test/tool/task.test.ts b/packages/opencode/test/tool/task.test.ts
index aae48a30a..fe936a242 100644
--- a/packages/opencode/test/tool/task.test.ts
+++ b/packages/opencode/test/tool/task.test.ts
@@ -1,7 +1,8 @@
+import { Effect } from "effect"
import { afterEach, describe, expect, test } from "bun:test"
import { Agent } from "../../src/agent/agent"
import { Instance } from "../../src/project/instance"
-import { TaskTool } from "../../src/tool/task"
+import { TaskDescription } from "../../src/tool/task"
import { tmpdir } from "../fixture/fixture"
afterEach(async () => {
@@ -28,16 +29,16 @@ describe("tool.task", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
- const build = await Agent.get("build")
- const first = await TaskTool.init({ agent: build })
- const second = await TaskTool.init({ agent: build })
+ const agent = { name: "build", mode: "primary" as const, permission: [], options: {} }
+ const first = await Effect.runPromise(TaskDescription(agent))
+ const second = await Effect.runPromise(TaskDescription(agent))
- expect(first.description).toBe(second.description)
+ expect(first).toBe(second)
- const alpha = first.description.indexOf("- alpha: Alpha agent")
- const explore = first.description.indexOf("- explore:")
- const general = first.description.indexOf("- general:")
- const zebra = first.description.indexOf("- zebra: Zebra agent")
+ const alpha = first.indexOf("- alpha: Alpha agent")
+ const explore = first.indexOf("- explore:")
+ const general = first.indexOf("- general:")
+ const zebra = first.indexOf("- zebra: Zebra agent")
expect(alpha).toBeGreaterThan(-1)
expect(explore).toBeGreaterThan(alpha)
diff --git a/packages/opencode/test/tool/tool-define.test.ts b/packages/opencode/test/tool/tool-define.test.ts
index 1503eed72..2ea6d56a5 100644
--- a/packages/opencode/test/tool/tool-define.test.ts
+++ b/packages/opencode/test/tool/tool-define.test.ts
@@ -3,7 +3,6 @@ import z from "zod"
import { Tool } from "../../src/tool/tool"
const params = z.object({ input: z.string() })
-const defaultArgs = { input: "test" }
function makeTool(id: string, executeFn?: () => void) {
return {
@@ -30,36 +29,6 @@ describe("Tool.define", () => {
expect(original.execute).toBe(originalExecute)
})
- test("object-defined tool does not accumulate wrapper layers across init() calls", async () => {
- let calls = 0
-
- const tool = Tool.define(
- "test-tool",
- makeTool("test", () => calls++),
- )
-
- for (let i = 0; i < 100; i++) {
- await tool.init()
- }
-
- const resolved = await tool.init()
- calls = 0
-
- let stack = ""
- const exec = resolved.execute
- resolved.execute = async (args: any, ctx: any) => {
- const result = await exec.call(resolved, args, ctx)
- stack = new Error().stack || ""
- return result
- }
-
- await resolved.execute(defaultArgs, {} as any)
- expect(calls).toBe(1)
-
- const frames = stack.split("\n").filter((l) => l.includes("tool.ts")).length
- expect(frames).toBeLessThan(5)
- })
-
test("function-defined tool returns fresh objects and is unaffected", async () => {
const tool = Tool.define("test-fn-tool", () => Promise.resolve(makeTool("test")))
@@ -77,25 +46,4 @@ describe("Tool.define", () => {
expect(first).not.toBe(second)
})
-
- test("validation still works after many init() calls", async () => {
- const tool = Tool.define("test-validation", {
- description: "validation test",
- parameters: z.object({ count: z.number().int().positive() }),
- async execute(args) {
- return { title: "test", output: String(args.count), metadata: {} }
- },
- })
-
- for (let i = 0; i < 100; i++) {
- await tool.init()
- }
-
- const resolved = await tool.init()
-
- const result = await resolved.execute({ count: 42 }, {} as any)
- expect(result.output).toBe("42")
-
- await expect(resolved.execute({ count: -1 }, {} as any)).rejects.toThrow("invalid arguments")
- })
})
diff --git a/packages/sdk/js/src/v2/client.ts b/packages/sdk/js/src/v2/client.ts
index e230a4b5d..67fe1de32 100644
--- a/packages/sdk/js/src/v2/client.ts
+++ b/packages/sdk/js/src/v2/client.ts
@@ -77,5 +77,6 @@ export function createOpencodeClient(config?: Config & { directory?: string; exp
workspace: config?.experimental_workspaceID,
}),
)
- return new OpencodeClient({ client })
+ const result = new OpencodeClient({ client })
+ return result
}
diff --git a/packages/sdk/js/src/v2/data.ts b/packages/sdk/js/src/v2/data.ts
new file mode 100644
index 000000000..baae6f278
--- /dev/null
+++ b/packages/sdk/js/src/v2/data.ts
@@ -0,0 +1,32 @@
+import type { Part, UserMessage } from "./client.js"
+
+export const message = {
+ user(input: Omit<UserMessage, "role" | "time" | "id"> & { parts: Omit<Part, "id" | "sessionID" | "messageID">[] }): {
+ info: UserMessage
+ parts: Part[]
+ } {
+ const { parts, ...rest } = input
+
+ const info: UserMessage = {
+ ...rest,
+ id: "asdasd",
+ time: {
+ created: Date.now(),
+ },
+ role: "user",
+ }
+
+ return {
+ info,
+ parts: input.parts.map(
+ (part) =>
+ ({
+ ...part,
+ id: "asdasd",
+ messageID: info.id,
+ sessionID: info.sessionID,
+ }) as Part,
+ ),
+ }
+ },
+}
diff --git a/packages/sdk/js/src/v2/index.ts b/packages/sdk/js/src/v2/index.ts
index d044f5ad6..d514784bc 100644
--- a/packages/sdk/js/src/v2/index.ts
+++ b/packages/sdk/js/src/v2/index.ts
@@ -5,6 +5,9 @@ import { createOpencodeClient } from "./client.js"
import { createOpencodeServer } from "./server.js"
import type { ServerOptions } from "./server.js"
+export * as data from "./data.js"
+import * as data from "./data.js"
+
export async function createOpencode(options?: ServerOptions) {
const server = await createOpencodeServer({
...options,