Browse Source

core: refactor tool system to remove agent context from initialization (#21052)

Dax 1 week ago
parent
commit
463318486f

+ 3 - 0
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=="],

+ 1 - 0
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": {

+ 1 - 5
packages/opencode/specs/v2.md → 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
 

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

+ 4 - 1
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>>) {

+ 6 - 1
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,

+ 10 - 11
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, {

+ 15 - 9
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)

+ 17 - 15
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) {

+ 0 - 183
packages/opencode/src/tool/batch.ts

@@ -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 })),
-        },
-      }
-    },
-  }
-})

+ 0 - 24
packages/opencode/src/tool/batch.txt

@@ -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.

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

+ 104 - 111
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))
   }
 }

+ 29 - 14
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")
+  })

+ 33 - 30
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")
+  })

+ 0 - 3
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:

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

+ 26 - 11
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,
+      }
+    })
+  }
 }

+ 20 - 20
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",

+ 13 - 10
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)

+ 10 - 9
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)

+ 0 - 52
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")
-  })
 })

+ 2 - 1
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
 }

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

+ 3 - 0
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,