From 949510afd085941ce9354cf8ceb9dcff5f02f660 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sat, 4 Apr 2026 16:18:01 -0400 Subject: [PATCH 01/10] sync --- bun.lock | 3 ++ package.json | 1 + packages/opencode/src/tool/bash.ts | 32 ++++++++++---------- packages/opencode/src/tool/batch.ts | 24 ++++++++------- packages/opencode/src/tool/skill.ts | 12 ++++---- packages/opencode/src/tool/websearch.ts | 40 ++++++++++++------------- 6 files changed, 60 insertions(+), 52 deletions(-) diff --git a/bun.lock b/bun.lock index 1c6bcd4716d9..897ca4fa93a1 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": ["he@1.2.0", "", { "bin": { "he": "bin/he" } }, "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="], + "heap-snapshot-toolkit": ["heap-snapshot-toolkit@1.1.3", "", {}, "sha512-joThu2rEsDu8/l4arupRDI1qP4CZXNG+J6Wr348vnbLGSiBkwRdqZ6aOHl5BzEiC+Dc8OTbMlmWjD0lbXD5K2Q=="], + "hey-listen": ["hey-listen@1.0.8", "", {}, "sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q=="], "hono": ["hono@4.10.7", "", {}, "sha512-icXIITfw/07Q88nLSkB9aiUrd8rYzSweK681Kjo/TSggaGbOX4RRyxxm71v+3PC8C/j+4rlxGeoTRxQDkaJkUw=="], diff --git a/package.json b/package.json index 4ce36d17ecd1..d4713f95daa4 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/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index e50f09cc38ce..365fda329604 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 index c79a530f71db..dd744fce8696 100644 --- a/packages/opencode/src/tool/batch.ts +++ b/packages/opencode/src/tool/batch.ts @@ -7,20 +7,22 @@ import DESCRIPTION from "./batch.txt" const DISALLOWED = new Set(["batch"]) const FILTERED_FROM_SUGGESTIONS = new Set(["invalid", "patch", ...DISALLOWED]) +const 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"), +}) + 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"), - }), + parameters: Parameters, formatValidationError(error) { const formattedErrors = error.issues .map((issue) => { diff --git a/packages/opencode/src/tool/skill.ts b/packages/opencode/src/tool/skill.ts index 17016b06f807..c5a77e58d215 100644 --- a/packages/opencode/src/tool/skill.ts +++ b/packages/opencode/src/tool/skill.ts @@ -6,6 +6,10 @@ import { Skill } from "../skill" import { Ripgrep } from "../file/ripgrep" import { iife } from "@/util/iife" +const Parameters = z.object({ + name: z.string().describe("The name of the skill from available_skills"), +}) + export const SkillTool = Tool.define("skill", async (ctx) => { const list = await Skill.available(ctx?.agent) @@ -33,14 +37,10 @@ export const SkillTool = Tool.define("skill", async (ctx) => { .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, ctx) { + parameters: Parameters, + async execute(params: z.infer, ctx) { const skill = await Skill.get(params.name) if (!skill) { diff --git a/packages/opencode/src/tool/websearch.ts b/packages/opencode/src/tool/websearch.ts index bf16428dfb3c..c0f1c8d10591 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", From 87a70ec3f59dc6a31edecf15fcabcdc0428b6a96 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sat, 4 Apr 2026 18:30:35 -0400 Subject: [PATCH 02/10] core: refactor tool system to remove agent context from initialization Simplify tool initialization by removing unnecessary agent context parameter from tool.init() calls. This makes tool behavior more predictable and consistent regardless of which agent is using them. Replace hardcoded named tool references (registry.named.task, registry.named.read) with a cleaner fromID() lookup method that works for any tool. Update TaskTool to display all available subagents without permission-based filtering, making it easier to discover and use subagents from any context. --- packages/opencode/src/cli/cmd/debug/agent.ts | 2 +- packages/opencode/src/session/prompt.ts | 20 +- packages/opencode/src/tool/question.ts | 2 +- packages/opencode/src/tool/registry.ts | 196 +++++++++---------- packages/opencode/src/tool/skill.ts | 4 +- packages/opencode/src/tool/task.ts | 15 +- packages/opencode/src/tool/todo.ts | 2 +- packages/opencode/src/tool/tool.ts | 36 ++-- packages/opencode/test/tool/task.test.ts | 5 +- 9 files changed, 134 insertions(+), 148 deletions(-) diff --git a/packages/opencode/src/cli/cmd/debug/agent.ts b/packages/opencode/src/cli/cmd/debug/agent.ts index 7f451e98c026..022e4ac9884a 100644 --- a/packages/opencode/src/cli/cmd/debug/agent.ts +++ b/packages/opencode/src/cli/cmd/debug/agent.ts @@ -71,7 +71,7 @@ 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) } async function resolveTools(agent: Agent.Info, availableTools: Awaited>) { diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index b91dfded5e6b..f9e392085022 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,10 @@ 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, + })) { const schema = ProviderTransform.schema(input.model, z.toJSONSchema(item.parameters)) tools[item.id] = tool({ id: item.id as any, @@ -560,7 +558,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 +581,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 +1111,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 +1175,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/tool/question.ts b/packages/opencode/src/tool/question.ts index dd99688880ea..23c9b35c8991 100644 --- a/packages/opencode/src/tool/question.ts +++ b/packages/opencode/src/tool/question.ts @@ -41,6 +41,6 @@ export const QuestionTool = Tool.defineEffect + } }), ) diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 9c045338ee63..e5dbebffe7df 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -12,10 +12,8 @@ import { WebFetchTool } from "./webfetch" import { WriteTool } from "./write" import { InvalidTool } from "./invalid" import { SkillTool } from "./skill" -import type { Agent } from "../agent/agent" 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 +26,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 +38,21 @@ 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 - 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 + readonly tools: (model: { providerID: ProviderID; modelID: ModelID }) => Effect.Effect + readonly fromID: (id: string) => Effect.Effect } export class Service extends ServiceMap.Service()("@opencode/ToolRegistry") {} @@ -79,33 +75,34 @@ export namespace ToolRegistry { const plugin = yield* Plugin.Service const build = (tool: T | Effect.Effect) => - Effect.isEffect(tool) ? tool : Effect.succeed(tool) + Effect.isEffect(tool) ? tool.pipe(Effect.flatMap(Tool.init)) : Tool.init(tool) const state = yield* InstanceState.make( 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 +128,96 @@ 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] : []), + ...(cfg.experimental?.batch_tool === true ? [BatchTool] : []), + ...(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") { + const tools: Interface["tools"] = Effect.fn("ToolRegistry.tools")(function* (model: { + providerID: ProviderID + modelID: ModelID + }) { + const filtered = (yield* all()).filter((tool) => { + if (tool.id === CodeSearchTool.id || tool.id === WebSearchTool.id) { return model.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 + 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, 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 +242,10 @@ 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(model: { + providerID: ProviderID + modelID: ModelID + }): Promise<(Tool.Def & { id: string })[]> { + return runPromise((svc) => svc.tools(model)) } } diff --git a/packages/opencode/src/tool/skill.ts b/packages/opencode/src/tool/skill.ts index c5a77e58d215..1d2b21654210 100644 --- a/packages/opencode/src/tool/skill.ts +++ b/packages/opencode/src/tool/skill.ts @@ -10,8 +10,8 @@ const Parameters = z.object({ name: z.string().describe("The name of the skill from available_skills"), }) -export const SkillTool = Tool.define("skill", async (ctx) => { - const list = await Skill.available(ctx?.agent) +export const SkillTool = Tool.define("skill", async () => { + const list = await Skill.all() const description = list.length === 0 diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index af130a70d919..f19f18f36f21 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -4,13 +4,11 @@ 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" const parameters = z.object({ description: z.string().describe("A short (3-5 words) description of the task"), @@ -25,19 +23,12 @@ const parameters = z.object({ 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")) - - // 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 + agents + .toSorted((a, b) => a.name.localeCompare(b.name)) .map((a) => `- ${a.name}: ${a.description ?? "This subagent should only be called manually by the user."}`) .join("\n"), ) diff --git a/packages/opencode/src/tool/todo.ts b/packages/opencode/src/tool/todo.ts index d10e84931ab0..92318164c667 100644 --- a/packages/opencode/src/tool/todo.ts +++ b/packages/opencode/src/tool/todo.ts @@ -43,6 +43,6 @@ export const TodoWriteTool = Tool.defineEffect + } satisfies Tool.DefWithoutID }), ) diff --git a/packages/opencode/src/tool/tool.ts b/packages/opencode/src/tool/tool.ts index a107dad7e8b5..2b222cdfb575 100644 --- a/packages/opencode/src/tool/tool.ts +++ b/packages/opencode/src/tool/tool.ts @@ -1,20 +1,16 @@ 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 - } - export type Context = { sessionID: SessionID messageID: MessageID @@ -26,7 +22,9 @@ export namespace Tool { metadata(input: { title?: string; metadata?: M }): void ask(input: Omit): Promise } + export interface Def { + id: string description: string parameters: Parameters execute( @@ -40,10 +38,14 @@ export namespace Tool { }> formatValidationError?(error: z.ZodError): string } + export type DefWithoutID = Omit< + Def, + "id" + > export interface Info { id: string - init: (ctx?: InitContext) => Promise> + init: () => Promise> } export type InferParameters = @@ -57,10 +59,10 @@ export namespace Tool { function wrap( id: string, - init: ((ctx?: InitContext) => Promise>) | Def, + init: (() => Promise>) | DefWithoutID, ) { - 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 +80,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 +97,7 @@ export namespace Tool { export function define( id: string, - init: ((ctx?: InitContext) => Promise>) | Def, + init: (() => Promise>) | DefWithoutID, ): Info { return { id, @@ -105,8 +107,18 @@ export namespace Tool { export function defineEffect( id: string, - init: Effect.Effect<((ctx?: InitContext) => Promise>) | Def, never, R>, + init: Effect.Effect<(() => Promise>) | DefWithoutID, never, R>, ): Effect.Effect, never, R> { return Effect.map(init, (next) => ({ id, init: wrap(id, next) })) } + + export function init(info: Info): Effect.Effect { + return Effect.gen(function* () { + const init = yield* Effect.promise(() => info.init()) + return { + ...init, + id: info.id, + } + }) + } } diff --git a/packages/opencode/test/tool/task.test.ts b/packages/opencode/test/tool/task.test.ts index aae48a30ab3f..f1fa2c314df7 100644 --- a/packages/opencode/test/tool/task.test.ts +++ b/packages/opencode/test/tool/task.test.ts @@ -28,9 +28,8 @@ 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 first = await TaskTool.init() + const second = await TaskTool.init() expect(first.description).toBe(second.description) From 570fb88b05a4648543de5cb8adde799988e25159 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sat, 4 Apr 2026 18:45:21 -0400 Subject: [PATCH 03/10] core: sort skills alphabetically in tool descriptions Skills are now displayed in alphabetical order when listing available skills, making it easier for users to locate specific skills in the output. --- packages/opencode/src/skill/index.ts | 24 +++++---- packages/opencode/src/tool/skill.ts | 1 - .../opencode/test/tool/tool-define.test.ts | 52 ------------------- 3 files changed, 15 insertions(+), 62 deletions(-) diff --git a/packages/opencode/src/skill/index.ts b/packages/opencode/src/skill/index.ts index a2ac3d351c86..cde36dd52d07 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 [ "", - ...list.flatMap((skill) => [ - " ", - ` ${skill.name}`, - ` ${skill.description}`, - ` ${pathToFileURL(skill.location).href}`, - " ", - ]), + ...list + .sort((a, b) => a.name.localeCompare(b.name)) + .flatMap((skill) => [ + " ", + ` ${skill.name}`, + ` ${skill.description}`, + ` ${pathToFileURL(skill.location).href}`, + " ", + ]), "", ].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/skill.ts b/packages/opencode/src/tool/skill.ts index 1d2b21654210..ab09a67c3a60 100644 --- a/packages/opencode/src/tool/skill.ts +++ b/packages/opencode/src/tool/skill.ts @@ -12,7 +12,6 @@ const Parameters = z.object({ export const SkillTool = Tool.define("skill", async () => { const list = await Skill.all() - const description = list.length === 0 ? "Load a specialized skill that provides domain-specific instructions and workflows. No skills are currently available." diff --git a/packages/opencode/test/tool/tool-define.test.ts b/packages/opencode/test/tool/tool-define.test.ts index 1503eed728f3..2ea6d56a51e8 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") - }) }) From 0e79eda0429bd358032343fc5ff114f2bce1e9f2 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 5 Apr 2026 17:56:01 -0400 Subject: [PATCH 04/10] more explorations --- packages/opencode/src/session/compaction.ts | 27 +++++++++-- packages/opencode/src/session/llm.ts | 54 ++++++++++----------- packages/sdk/js/src/v2/client.ts | 3 +- packages/sdk/js/src/v2/data.ts | 32 ++++++++++++ packages/sdk/js/src/v2/index.ts | 3 ++ 5 files changed, 85 insertions(+), 34 deletions(-) create mode 100644 packages/sdk/js/src/v2/data.ts diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index bbdce9fd7472..825a34a39b01 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -2,7 +2,6 @@ import { BusEvent } from "@/bus/bus-event" import { Bus } from "@/bus" import { Session } from "." import { SessionID, MessageID, PartID } from "./schema" -import { Instance } from "../project/instance" import { Provider } from "../provider/provider" import { MessageV2 } from "./message-v2" import z from "zod" @@ -219,7 +218,6 @@ When constructing the summary, try to stick to this template: const prompt = compacting.prompt ?? [defaultPrompt, ...compacting.context].join("\n\n") const msgs = structuredClone(messages) yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs }) - const modelMessages = yield* MessageV2.toModelMessagesEffect(msgs, model, { stripMedia: true }) const ctx = yield* InstanceState.context const msg: MessageV2.Assistant = { id: MessageID.ascending(), @@ -261,10 +259,29 @@ When constructing the summary, try to stick to this template: tools: {}, system: [], messages: [ - ...modelMessages, + ...msgs, { - role: "user", - content: [{ type: "text", text: prompt }], + info: { + role: "user", + sessionID: input.sessionID, + id: MessageID.ascending(), + time: { created: Date.now() }, + agent: agent.name, + model: { + modelID: model.id, + providerID: model.providerID, + }, + }, + parts: [ + { + type: "text", + text: prompt, + sessionID: input.sessionID, + messageID: MessageID.ascending(), + id: PartID.ascending(), + synthetic: true, + }, + ], }, ], model, diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index c9a62c8645e0..a0afb356921b 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -1,7 +1,6 @@ import { Provider } from "@/provider/provider" import { Log } from "@/util/log" -import { Cause, Effect, Layer, Record, ServiceMap } from "effect" -import * as Queue from "effect/Queue" +import { Effect, Layer, Record, ServiceMap } from "effect" import * as Stream from "effect/Stream" import { streamText, wrapLanguageModel, type ModelMessage, type Tool, tool, jsonSchema } from "ai" import { mergeDeep, pipe } from "remeda" @@ -10,13 +9,14 @@ import { ProviderTransform } from "@/provider/transform" import { Config } from "@/config/config" import { Instance } from "@/project/instance" import type { Agent } from "@/agent/agent" -import type { MessageV2 } from "./message-v2" +import { MessageV2 } from "./message-v2" import { Plugin } from "@/plugin" import { SystemPrompt } from "./system" import { Flag } from "@/flag/flag" import { Permission } from "@/permission" import { Auth } from "@/auth" import { Installation } from "@/installation" +import { iife } from "@/util/iife" export namespace LLM { const log = Log.create({ service: "llm" }) @@ -30,7 +30,7 @@ export namespace LLM { agent: Agent.Info permission?: Permission.Ruleset system: string[] - messages: ModelMessage[] + messages: MessageV2.WithParts[] small?: boolean tools: Record retries?: number @@ -148,19 +148,23 @@ export namespace LLM { } const isWorkflow = language instanceof GitLabWorkflowLanguageModel - const messages = isOpenaiOauth - ? input.messages - : isWorkflow - ? input.messages - : [ - ...system.map( - (x): ModelMessage => ({ - role: "system", - content: x, - }), - ), - ...input.messages, - ] + const messages = await iife(async () => { + if (isOpenaiOauth || isWorkflow) return MessageV2.toModelMessages(input.messages, input.model) + return [ + ...system.map( + (x): ModelMessage => ({ + role: "system", + content: x, + }), + ), + ...(await MessageV2.toModelMessages(input.messages, input.model)), + ] + }) + + const maxOutputTokens = + isOpenaiOauth || provider.id.includes("github-copilot") + ? undefined + : ProviderTransform.maxOutputTokens(input.model) const params = await Plugin.trigger( "chat.params", @@ -177,7 +181,7 @@ export namespace LLM { : undefined, topP: input.agent.topP ?? ProviderTransform.topP(input.model), topK: ProviderTransform.topK(input.model), - maxOutputTokens: ProviderTransform.maxOutputTokens(input.model), + maxOutputTokens, options, }, ) @@ -196,7 +200,7 @@ export namespace LLM { }, ) - const tools = await resolveTools(input) + const tools = resolveTools(input) // LiteLLM and some Anthropic proxies require the tools parameter to be present // when message history contains tool calls, even if no tools are being used. @@ -240,7 +244,7 @@ export namespace LLM { try { const result = await t.execute!(JSON.parse(argsJson), { toolCallId: _requestID, - messages: input.messages, + messages: [], abortSignal: input.abort, }) const output = typeof result === "string" ? result : (result?.output ?? JSON.stringify(result)) @@ -344,13 +348,7 @@ export namespace LLM { // Check if messages contain any tool-call content // Used to determine if a dummy tool should be added for LiteLLM proxy compatibility - export function hasToolCalls(messages: ModelMessage[]): boolean { - for (const msg of messages) { - if (!Array.isArray(msg.content)) continue - for (const part of msg.content) { - if (part.type === "tool-call" || part.type === "tool-result") return true - } - } - return false + export function hasToolCalls(messages: MessageV2.WithParts[]): boolean { + return messages.some((msg) => msg.parts.some((part) => part.type === "tool")) } } diff --git a/packages/sdk/js/src/v2/client.ts b/packages/sdk/js/src/v2/client.ts index e230a4b5dc77..67fe1de32f72 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 000000000000..baae6f278d98 --- /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 & { parts: Omit[] }): { + 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 d044f5ad66e4..d514784bc292 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, From 2784d7a153a794592de17ed0847c09d0c55cb3f4 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 5 Apr 2026 19:12:27 -0400 Subject: [PATCH 05/10] core: add message shape design options for v2 prompt hooks Document two approaches for handling synthetic messages in prompt hooks: - Option 1: Separate PromptMessage type for lightweight prompt surgery - Option 2: PromptEditor API with append/prepend/insert mutators This enables plugin developers to inject instructions or context into prompts without manually fabricating message IDs and timestamps. The design supports resumable sessions while keeping the API simple for common prompt manipulation use cases. --- packages/opencode/specs/v2.md | 85 +++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/packages/opencode/specs/v2.md b/packages/opencode/specs/v2.md index 66b4d2dea477..897b98ba6af9 100644 --- a/packages/opencode/specs/v2.md +++ b/packages/opencode/specs/v2.md @@ -12,3 +12,88 @@ Make it `keymappings`, closer to neovim. Can be layered like `abc`. Comm _Why_ Currently its keybindings that have an `id` like `message_redo` and then a command can use that or define it's own binding. While some keybindings are just used with `.match` in arbitrary key handlers and there is no info what the key is used for, except the binding id maybe. It also is unknown in which context/scope what binding is active, so a plugin like `which-key` is nearly impossible to get right. + +## 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 + } +} + +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. From 34d606e2fbd910edfbab827df4d9289f254d128d Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 5 Apr 2026 20:09:45 -0400 Subject: [PATCH 06/10] core: split v2 design notes into topic docs for easier review --- packages/opencode/specs/v2/keymappings.md | 10 +++ .../specs/{v2.md => v2/message-shape.md} | 73 ++++++++++++++----- 2 files changed, 65 insertions(+), 18 deletions(-) create mode 100644 packages/opencode/specs/v2/keymappings.md rename packages/opencode/specs/{v2.md => v2/message-shape.md} (66%) diff --git a/packages/opencode/specs/v2/keymappings.md b/packages/opencode/specs/v2/keymappings.md new file mode 100644 index 000000000000..5b23db795493 --- /dev/null +++ b/packages/opencode/specs/v2/keymappings.md @@ -0,0 +1,10 @@ +# Keybindings vs. Keymappings + +Make it `keymappings`, closer to neovim. Can be layered like `abc`. Commands don't define their binding, but have an id that a key can be mapped to like + +```ts +{ key: "ctrl+w", cmd: string | function, description } +``` + +_Why_ +Currently its keybindings that have an `id` like `message_redo` and then a command can use that or define it's own binding. While some keybindings are just used with `.match` in arbitrary key handlers and there is no info what the key is used for, except the binding id maybe. It also is unknown in which context/scope what binding is active, so a plugin like `which-key` is nearly impossible to get right. diff --git a/packages/opencode/specs/v2.md b/packages/opencode/specs/v2/message-shape.md similarity index 66% rename from packages/opencode/specs/v2.md rename to packages/opencode/specs/v2/message-shape.md index 897b98ba6af9..965498f19034 100644 --- a/packages/opencode/specs/v2.md +++ b/packages/opencode/specs/v2/message-shape.md @@ -1,19 +1,4 @@ -# 2.0 - -What we would change if we could - -## Keybindings vs. Keymappings - -Make it `keymappings`, closer to neovim. Can be layered like `abc`. Commands don't define their binding, but have an id that a key can be mapped to like - -```ts -{ key: "ctrl+w", cmd: string | function, description } -``` - -_Why_ -Currently its keybindings that have an `id` like `message_redo` and then a command can use that or define it's own binding. While some keybindings are just used with `.match` in arbitrary key handlers and there is no info what the key is used for, except the binding id maybe. It also is unknown in which context/scope what binding is active, so a plugin like `which-key` is nearly impossible to get right. - -## Message Shape +# Message Shape Problem: @@ -21,7 +6,7 @@ Problem: - 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 +## Option 1: Two Message Shapes Keep `User` / `Assistant` for stored history, but clean them up. @@ -67,7 +52,7 @@ prompt.push({ Tradeoff: prompt hooks get easy lightweight messages, but there are now two message shapes. -### Option 2: Prompt Mutators +## Option 2: Prompt Mutators Keep `User` / `Assistant` as the stored history model. @@ -97,3 +82,55 @@ 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 + } +} + +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. From 44b54655ec4ffbbc693194eb2f412ee87f7d3a3e Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Tue, 7 Apr 2026 17:55:38 -0400 Subject: [PATCH 07/10] core: fix session compaction message format conversion Fixed the compaction system to properly convert internal message format to model message format before sending to LLM for summarization. This ensures session compaction works reliably across different providers. - Use MessageV2.toModelMessagesEffect to convert messages with media stripped - Fix hasToolCalls to work with ModelMessage[] format - Pass message history to tool execution context during compaction --- packages/opencode/src/session/compaction.ts | 27 ++--------- packages/opencode/src/session/llm.ts | 54 +++++++++++---------- 2 files changed, 33 insertions(+), 48 deletions(-) diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index 825a34a39b01..bbdce9fd7472 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -2,6 +2,7 @@ import { BusEvent } from "@/bus/bus-event" import { Bus } from "@/bus" import { Session } from "." import { SessionID, MessageID, PartID } from "./schema" +import { Instance } from "../project/instance" import { Provider } from "../provider/provider" import { MessageV2 } from "./message-v2" import z from "zod" @@ -218,6 +219,7 @@ When constructing the summary, try to stick to this template: const prompt = compacting.prompt ?? [defaultPrompt, ...compacting.context].join("\n\n") const msgs = structuredClone(messages) yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs }) + const modelMessages = yield* MessageV2.toModelMessagesEffect(msgs, model, { stripMedia: true }) const ctx = yield* InstanceState.context const msg: MessageV2.Assistant = { id: MessageID.ascending(), @@ -259,29 +261,10 @@ When constructing the summary, try to stick to this template: tools: {}, system: [], messages: [ - ...msgs, + ...modelMessages, { - info: { - role: "user", - sessionID: input.sessionID, - id: MessageID.ascending(), - time: { created: Date.now() }, - agent: agent.name, - model: { - modelID: model.id, - providerID: model.providerID, - }, - }, - parts: [ - { - type: "text", - text: prompt, - sessionID: input.sessionID, - messageID: MessageID.ascending(), - id: PartID.ascending(), - synthetic: true, - }, - ], + role: "user", + content: [{ type: "text", text: prompt }], }, ], model, diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index a0afb356921b..c9a62c8645e0 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -1,6 +1,7 @@ import { Provider } from "@/provider/provider" import { Log } from "@/util/log" -import { Effect, Layer, Record, ServiceMap } from "effect" +import { Cause, Effect, Layer, Record, ServiceMap } from "effect" +import * as Queue from "effect/Queue" import * as Stream from "effect/Stream" import { streamText, wrapLanguageModel, type ModelMessage, type Tool, tool, jsonSchema } from "ai" import { mergeDeep, pipe } from "remeda" @@ -9,14 +10,13 @@ import { ProviderTransform } from "@/provider/transform" import { Config } from "@/config/config" import { Instance } from "@/project/instance" import type { Agent } from "@/agent/agent" -import { MessageV2 } from "./message-v2" +import type { MessageV2 } from "./message-v2" import { Plugin } from "@/plugin" import { SystemPrompt } from "./system" import { Flag } from "@/flag/flag" import { Permission } from "@/permission" import { Auth } from "@/auth" import { Installation } from "@/installation" -import { iife } from "@/util/iife" export namespace LLM { const log = Log.create({ service: "llm" }) @@ -30,7 +30,7 @@ export namespace LLM { agent: Agent.Info permission?: Permission.Ruleset system: string[] - messages: MessageV2.WithParts[] + messages: ModelMessage[] small?: boolean tools: Record retries?: number @@ -148,23 +148,19 @@ export namespace LLM { } const isWorkflow = language instanceof GitLabWorkflowLanguageModel - const messages = await iife(async () => { - if (isOpenaiOauth || isWorkflow) return MessageV2.toModelMessages(input.messages, input.model) - return [ - ...system.map( - (x): ModelMessage => ({ - role: "system", - content: x, - }), - ), - ...(await MessageV2.toModelMessages(input.messages, input.model)), - ] - }) - - const maxOutputTokens = - isOpenaiOauth || provider.id.includes("github-copilot") - ? undefined - : ProviderTransform.maxOutputTokens(input.model) + const messages = isOpenaiOauth + ? input.messages + : isWorkflow + ? input.messages + : [ + ...system.map( + (x): ModelMessage => ({ + role: "system", + content: x, + }), + ), + ...input.messages, + ] const params = await Plugin.trigger( "chat.params", @@ -181,7 +177,7 @@ export namespace LLM { : undefined, topP: input.agent.topP ?? ProviderTransform.topP(input.model), topK: ProviderTransform.topK(input.model), - maxOutputTokens, + maxOutputTokens: ProviderTransform.maxOutputTokens(input.model), options, }, ) @@ -200,7 +196,7 @@ export namespace LLM { }, ) - const tools = resolveTools(input) + const tools = await resolveTools(input) // LiteLLM and some Anthropic proxies require the tools parameter to be present // when message history contains tool calls, even if no tools are being used. @@ -244,7 +240,7 @@ export namespace LLM { try { const result = await t.execute!(JSON.parse(argsJson), { toolCallId: _requestID, - messages: [], + messages: input.messages, abortSignal: input.abort, }) const output = typeof result === "string" ? result : (result?.output ?? JSON.stringify(result)) @@ -348,7 +344,13 @@ export namespace LLM { // Check if messages contain any tool-call content // Used to determine if a dummy tool should be added for LiteLLM proxy compatibility - export function hasToolCalls(messages: MessageV2.WithParts[]): boolean { - return messages.some((msg) => msg.parts.some((part) => part.type === "tool")) + export function hasToolCalls(messages: ModelMessage[]): boolean { + for (const msg of messages) { + if (!Array.isArray(msg.content)) continue + for (const part of msg.content) { + if (part.type === "tool-call" || part.type === "tool-result") return true + } + } + return false } } From 791c932d51fe546a4a49e20a1c818166ea0f9f52 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Tue, 7 Apr 2026 18:46:28 -0400 Subject: [PATCH 08/10] sync --- packages/opencode/src/session/prompt.ts | 1 + packages/opencode/src/tool/registry.ts | 33 +++++++++------ packages/opencode/src/tool/skill.ts | 48 ++++++++++------------ packages/opencode/src/tool/task.ts | 53 ++++++++++++++----------- packages/opencode/src/tool/task.txt | 3 -- packages/opencode/src/tool/tool.ts | 3 ++ 6 files changed, 76 insertions(+), 65 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index f9e392085022..c29733999214 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -434,6 +434,7 @@ 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, + agent: input.agent, })) { const schema = ProviderTransform.schema(input.model, z.toJSONSchema(item.parameters)) tools[item.id] = tool({ diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index e5dbebffe7df..c395f729e016 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -6,12 +6,12 @@ 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 { SkillDescription, SkillTool } from "./skill" import { Tool } from "./tool" import { Config } from "../config/config" import { type ToolContext as PluginToolContext, type ToolDefinition } from "@opencode-ai/plugin" @@ -51,7 +51,11 @@ export namespace ToolRegistry { export interface Interface { readonly ids: () => Effect.Effect readonly all: () => Effect.Effect - readonly tools: (model: { providerID: ProviderID; modelID: ModelID }) => Effect.Effect + readonly tools: (model: { + providerID: ProviderID + modelID: ModelID + agent: Agent.Info + }) => Effect.Effect readonly fromID: (id: string) => Effect.Effect } @@ -178,18 +182,15 @@ export namespace ToolRegistry { return (yield* all()).map((tool) => tool.id) }) - const tools: Interface["tools"] = Effect.fn("ToolRegistry.tools")(function* (model: { - providerID: ProviderID - modelID: ModelID - }) { + 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 model.providerID === ProviderID.opencode || Flag.OPENCODE_ENABLE_EXA + 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")) + (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 @@ -207,7 +208,14 @@ export namespace ToolRegistry { 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: tool.execute, formatValidationError: tool.formatValidationError, @@ -242,10 +250,11 @@ export namespace ToolRegistry { return runPromise((svc) => svc.ids()) } - export async function tools(model: { + export async function tools(input: { providerID: ProviderID modelID: ModelID + agent: Agent.Info }): Promise<(Tool.Def & { id: string })[]> { - return runPromise((svc) => svc.tools(model)) + return runPromise((svc) => svc.tools(input)) } } diff --git a/packages/opencode/src/tool/skill.ts b/packages/opencode/src/tool/skill.ts index ab09a67c3a60..6b18685a4e0a 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" @@ -11,33 +12,8 @@ const Parameters = z.object({ }) export const SkillTool = Tool.define("skill", async () => { - const list = await Skill.all() - const description = - list.length === 0 - ? "Load a specialized skill that provides domain-specific instructions and workflows. No skills are currently available." - : [ - "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 `` 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") - - const examples = list - .map((skill) => `'${skill.name}'`) - .slice(0, 3) - .join(", ") - const hint = examples.length > 0 ? ` (e.g., ${examples}, ...)` : "" - return { - description, + description: `Load a specialized skill that provides domain-specific instructions and workflows.`, parameters: Parameters, async execute(params: z.infer, ctx) { const skill = await Skill.get(params.name) @@ -102,3 +78,23 @@ export const SkillTool = Tool.define("skill", async () => { }, } }) + +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 `` 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 f19f18f36f21..49a1674fa1e2 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -9,33 +9,25 @@ import { SessionPrompt } from "../session/prompt" import { iife } from "@/util/iife" import { defer } from "@/util/defer" import { Config } from "../config/config" - -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(), -}) +import { Permission } from "@/permission" +import { Effect } from "effect" export const TaskTool = Tool.define("task", async () => { - const agents = await Agent.list().then((x) => x.filter((a) => a.mode !== "primary")) - const description = DESCRIPTION.replace( - "{agents}", - agents - .toSorted((a, b) => a.name.localeCompare(b.name)) - .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, ctx) { + description: DESCRIPTION, + 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 @@ -155,3 +147,16 @@ export const TaskTool = Tool.define("task", async () => { }, } }) + +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 585cce8f9d0a..fba8470d1b4b 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/tool.ts b/packages/opencode/src/tool/tool.ts index 2b222cdfb575..6d129f4271b4 100644 --- a/packages/opencode/src/tool/tool.ts +++ b/packages/opencode/src/tool/tool.ts @@ -11,6 +11,9 @@ export namespace Tool { [key: string]: any } + // TODO: remove this hack + export type DynamicDescription = (agent: Agent.Info) => Effect.Effect + export type Context = { sessionID: SessionID messageID: MessageID From f26eed7beb1aed56984489ef40548b85cebfb437 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Tue, 7 Apr 2026 18:52:13 -0400 Subject: [PATCH 09/10] kill batch tool --- packages/opencode/src/cli/cmd/debug/agent.ts | 5 +- .../src/server/routes/experimental.ts | 7 +- packages/opencode/src/tool/batch.ts | 185 ------------------ packages/opencode/src/tool/batch.txt | 24 --- packages/opencode/src/tool/registry.ts | 2 - 5 files changed, 10 insertions(+), 213 deletions(-) delete mode 100644 packages/opencode/src/tool/batch.ts delete mode 100644 packages/opencode/src/tool/batch.txt diff --git a/packages/opencode/src/cli/cmd/debug/agent.ts b/packages/opencode/src/cli/cmd/debug/agent.ts index 022e4ac9884a..458f92547437 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) + return ToolRegistry.tools({ + ...model, + agent, + }) } async function resolveTools(agent: Agent.Info, availableTools: Awaited>) { diff --git a/packages/opencode/src/server/routes/experimental.ts b/packages/opencode/src/server/routes/experimental.ts index c673393d0e36..763cdcf7749d 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/tool/batch.ts b/packages/opencode/src/tool/batch.ts deleted file mode 100644 index dd744fce8696..000000000000 --- a/packages/opencode/src/tool/batch.ts +++ /dev/null @@ -1,185 +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]) - -const 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"), -}) - -export const BatchTool = Tool.define("batch", async () => { - return { - description: DESCRIPTION, - parameters: Parameters, - 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 968a6c3f07c6..000000000000 --- 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/registry.ts b/packages/opencode/src/tool/registry.ts index c395f729e016..72911051e0ea 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -4,7 +4,6 @@ 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 { TaskDescription, TaskTool } from "./task" import { TodoWriteTool } from "./todo" @@ -156,7 +155,6 @@ export namespace ToolRegistry { ApplyPatchTool, ...(question ? [QuestionTool] : []), ...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [LspTool] : []), - ...(cfg.experimental?.batch_tool === true ? [BatchTool] : []), ...(Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE && Flag.OPENCODE_CLIENT === "cli" ? [PlanExitTool] : []), ], build, From 17b7230fe6a569461c05057116f7407da3fab727 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Tue, 7 Apr 2026 19:22:56 -0400 Subject: [PATCH 10/10] core: show available skills and agents in tool descriptions Dynamically populate the skill and task tool descriptions with lists of available skills and agent types. This helps users see what specialized capabilities are available before invoking these tools, rather than discovering them through trial and error. --- packages/opencode/src/tool/skill.ts | 22 +++++++++++++++++++++- packages/opencode/src/tool/task.ts | 9 ++++++++- packages/opencode/test/tool/skill.test.ts | 23 +++++++++++++---------- packages/opencode/test/tool/task.test.ts | 18 ++++++++++-------- 4 files changed, 52 insertions(+), 20 deletions(-) diff --git a/packages/opencode/src/tool/skill.ts b/packages/opencode/src/tool/skill.ts index 6b18685a4e0a..276f3931d012 100644 --- a/packages/opencode/src/tool/skill.ts +++ b/packages/opencode/src/tool/skill.ts @@ -12,8 +12,28 @@ const Parameters = z.object({ }) export const SkillTool = Tool.define("skill", async () => { + const list = await Skill.available() + + const description = + list.length === 0 + ? "Load a specialized skill that provides domain-specific instructions and workflows. No skills are currently available." + : [ + "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 `` 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") + return { - description: `Load a specialized skill that provides domain-specific instructions and workflows.`, + description, parameters: Parameters, async execute(params: z.infer, ctx) { const skill = await Skill.get(params.name) diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index 49a1674fa1e2..07e779f5bd56 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -13,8 +13,15 @@ import { Permission } from "@/permission" import { Effect } from "effect" 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") + return { - description: DESCRIPTION, + description, 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"), diff --git a/packages/opencode/test/tool/skill.test.ts b/packages/opencode/test/tool/skill.test.ts index ffae223f98aa..e6269a4f389f 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 f1fa2c314df7..fe936a242aaf 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,15 +29,16 @@ describe("tool.task", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const first = await TaskTool.init() - const second = await TaskTool.init() + 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)