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/specs/v2.md b/packages/opencode/specs/v2/keymappings.md similarity index 89% rename from packages/opencode/specs/v2.md rename to packages/opencode/specs/v2/keymappings.md index 66b4d2dea477..5b23db795493 100644 --- a/packages/opencode/specs/v2.md +++ b/packages/opencode/specs/v2/keymappings.md @@ -1,8 +1,4 @@ -# 2.0 - -What we would change if we could - -## Keybindings vs. Keymappings +# Keybindings vs. Keymappings Make it `keymappings`, closer to neovim. Can be layered like `abc`. Commands don't define their binding, but have an id that a key can be mapped to like diff --git a/packages/opencode/specs/v2/message-shape.md b/packages/opencode/specs/v2/message-shape.md new file mode 100644 index 000000000000..965498f19034 --- /dev/null +++ b/packages/opencode/specs/v2/message-shape.md @@ -0,0 +1,136 @@ +# Message Shape + +Problem: + +- stored messages need enough data to replay and resume a session later +- prompt hooks often just want to append a synthetic user/assistant message +- today that means faking ids, timestamps, and request metadata + +## Option 1: Two Message Shapes + +Keep `User` / `Assistant` for stored history, but clean them up. + +```ts +type User = { + role: "user" + time: { created: number } + request: { + agent: string + model: ModelRef + variant?: string + format?: OutputFormat + system?: string + tools?: Record + } +} + +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 + } +} + +type User = { + role: "user" + turnID: string + time: { created: number } +} + +type Assistant = { + role: "assistant" + turnID: string + usage: { cost: number; tokens: Tokens } + result: { finish?: string; error?: Error; structured?: unknown; kind: "reply" | "summary" } +} +``` + +Examples: + +```ts +const turn = { + request: { + agent: "build", + model: { providerID: "openai", modelID: "gpt-5" }, + }, +} +``` + +```ts +const msg = { + role: "user", + turnID: turn.id, + parts: [{ type: "text", text: "Summarize the tool output above and continue." }], +} +``` + +Tradeoff: stored messages get much smaller and cleaner, but replay now has to join messages with turn state and prompt hooks still need a way to pick which turn they belong to. diff --git a/packages/opencode/src/cli/cmd/debug/agent.ts b/packages/opencode/src/cli/cmd/debug/agent.ts index 7f451e98c026..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, agent) + 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/session/prompt.ts b/packages/opencode/src/session/prompt.ts index b91dfded5e6b..c29733999214 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -11,7 +11,6 @@ import { Provider } from "../provider/provider" import { ModelID, ProviderID } from "../provider/schema" import { type Tool as AITool, tool, jsonSchema, type ToolExecutionOptions, asSchema } from "ai" import { SessionCompaction } from "./compaction" -import { Instance } from "../project/instance" import { Bus } from "../bus" import { ProviderTransform } from "../provider/transform" import { SystemPrompt } from "./system" @@ -24,7 +23,6 @@ import { ToolRegistry } from "../tool/registry" import { Runner } from "@/effect/runner" import { MCP } from "../mcp" import { LSP } from "../lsp" -import { ReadTool } from "../tool/read" import { FileTime } from "../file/time" import { Flag } from "../flag/flag" import { ulid } from "ulid" @@ -37,7 +35,6 @@ import { ConfigMarkdown } from "../config/markdown" import { SessionSummary } from "./summary" import { NamedError } from "@opencode-ai/util/error" import { SessionProcessor } from "./processor" -import { TaskTool } from "@/tool/task" import { Tool } from "@/tool/tool" import { Permission } from "@/permission" import { SessionStatus } from "./status" @@ -50,6 +47,7 @@ import { Process } from "@/util/process" import { Cause, Effect, Exit, Layer, Option, Scope, ServiceMap } from "effect" import { InstanceState } from "@/effect/instance-state" import { makeRuntime } from "@/effect/run-service" +import { TaskTool } from "@/tool/task" // @ts-ignore globalThis.AI_SDK_LOG_WARNINGS = false @@ -433,10 +431,11 @@ NOTE: At any point in time through this workflow you should feel free to ask the ), }) - for (const item of yield* registry.tools( - { modelID: ModelID.make(input.model.api.id), providerID: input.model.providerID }, - input.agent, - )) { + for (const item of yield* registry.tools({ + modelID: ModelID.make(input.model.api.id), + providerID: input.model.providerID, + agent: input.agent, + })) { const schema = ProviderTransform.schema(input.model, z.toJSONSchema(item.parameters)) tools[item.id] = tool({ id: item.id as any, @@ -560,7 +559,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the }) { const { task, model, lastUser, sessionID, session, msgs } = input const ctx = yield* InstanceState.context - const taskTool = yield* Effect.promise(() => registry.named.task.init()) + const taskTool = yield* registry.fromID(TaskTool.id) const taskModel = task.model ? yield* getModel(task.model.providerID, task.model.modelID, sessionID) : model const assistantMessage: MessageV2.Assistant = yield* sessions.updateMessage({ id: MessageID.ascending(), @@ -583,7 +582,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the sessionID: assistantMessage.sessionID, type: "tool", callID: ulid(), - tool: registry.named.task.id, + tool: TaskTool.id, state: { status: "running", input: { @@ -1113,7 +1112,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the text: `Called the Read tool with the following input: ${JSON.stringify(args)}`, }, ] - const read = yield* Effect.promise(() => registry.named.read.init()).pipe( + const read = yield* registry.fromID("read").pipe( Effect.flatMap((t) => provider.getModel(info.model.providerID, info.model.modelID).pipe( Effect.flatMap((mdl) => @@ -1177,7 +1176,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the if (part.mime === "application/x-directory") { const args = { filePath: filepath } - const result = yield* Effect.promise(() => registry.named.read.init()).pipe( + const result = yield* registry.fromID("read").pipe( Effect.flatMap((t) => Effect.promise(() => t.execute(args, { diff --git a/packages/opencode/src/skill/index.ts b/packages/opencode/src/skill/index.ts index 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/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 deleted file mode 100644 index c79a530f71db..000000000000 --- a/packages/opencode/src/tool/batch.ts +++ /dev/null @@ -1,183 +0,0 @@ -import z from "zod" -import { Tool } from "./tool" -import { ProviderID, ModelID } from "../provider/schema" -import { errorMessage } from "../util/error" -import DESCRIPTION from "./batch.txt" - -const DISALLOWED = new Set(["batch"]) -const FILTERED_FROM_SUGGESTIONS = new Set(["invalid", "patch", ...DISALLOWED]) - -export const BatchTool = Tool.define("batch", async () => { - return { - description: DESCRIPTION, - parameters: z.object({ - tool_calls: z - .array( - z.object({ - tool: z.string().describe("The name of the tool to execute"), - parameters: z.object({}).loose().describe("Parameters for the tool"), - }), - ) - .min(1, "Provide at least one tool call") - .describe("Array of tool calls to execute in parallel"), - }), - formatValidationError(error) { - const formattedErrors = error.issues - .map((issue) => { - const path = issue.path.length > 0 ? issue.path.join(".") : "root" - return ` - ${path}: ${issue.message}` - }) - .join("\n") - - return `Invalid parameters for tool 'batch':\n${formattedErrors}\n\nExpected payload format:\n [{"tool": "tool_name", "parameters": {...}}, {...}]` - }, - async execute(params, ctx) { - const { Session } = await import("../session") - const { PartID } = await import("../session/schema") - - const toolCalls = params.tool_calls.slice(0, 25) - const discardedCalls = params.tool_calls.slice(25) - - const { ToolRegistry } = await import("./registry") - const availableTools = await ToolRegistry.tools({ modelID: ModelID.make(""), providerID: ProviderID.make("") }) - const toolMap = new Map(availableTools.map((t) => [t.id, t])) - - const executeCall = async (call: (typeof toolCalls)[0]) => { - const callStartTime = Date.now() - const partID = PartID.ascending() - - try { - if (DISALLOWED.has(call.tool)) { - throw new Error( - `Tool '${call.tool}' is not allowed in batch. Disallowed tools: ${Array.from(DISALLOWED).join(", ")}`, - ) - } - - const tool = toolMap.get(call.tool) - if (!tool) { - const availableToolsList = Array.from(toolMap.keys()).filter((name) => !FILTERED_FROM_SUGGESTIONS.has(name)) - throw new Error( - `Tool '${call.tool}' not in registry. External tools (MCP, environment) cannot be batched - call them directly. Available tools: ${availableToolsList.join(", ")}`, - ) - } - const validatedParams = tool.parameters.parse(call.parameters) - - await Session.updatePart({ - id: partID, - messageID: ctx.messageID, - sessionID: ctx.sessionID, - type: "tool", - tool: call.tool, - callID: partID, - state: { - status: "running", - input: call.parameters, - time: { - start: callStartTime, - }, - }, - }) - - const result = await tool.execute(validatedParams, { ...ctx, callID: partID }) - const attachments = result.attachments?.map((attachment) => ({ - ...attachment, - id: PartID.ascending(), - sessionID: ctx.sessionID, - messageID: ctx.messageID, - })) - - await Session.updatePart({ - id: partID, - messageID: ctx.messageID, - sessionID: ctx.sessionID, - type: "tool", - tool: call.tool, - callID: partID, - state: { - status: "completed", - input: call.parameters, - output: result.output, - title: result.title, - metadata: result.metadata, - attachments, - time: { - start: callStartTime, - end: Date.now(), - }, - }, - }) - - return { success: true as const, tool: call.tool, result } - } catch (error) { - await Session.updatePart({ - id: partID, - messageID: ctx.messageID, - sessionID: ctx.sessionID, - type: "tool", - tool: call.tool, - callID: partID, - state: { - status: "error", - input: call.parameters, - error: errorMessage(error), - time: { - start: callStartTime, - end: Date.now(), - }, - }, - }) - - return { success: false as const, tool: call.tool, error } - } - } - - const results = await Promise.all(toolCalls.map((call) => executeCall(call))) - - // Add discarded calls as errors - const now = Date.now() - for (const call of discardedCalls) { - const partID = PartID.ascending() - await Session.updatePart({ - id: partID, - messageID: ctx.messageID, - sessionID: ctx.sessionID, - type: "tool", - tool: call.tool, - callID: partID, - state: { - status: "error", - input: call.parameters, - error: "Maximum of 25 tools allowed in batch", - time: { start: now, end: now }, - }, - }) - results.push({ - success: false as const, - tool: call.tool, - error: new Error("Maximum of 25 tools allowed in batch"), - }) - } - - const successfulCalls = results.filter((r) => r.success).length - const failedCalls = results.length - successfulCalls - - const outputMessage = - failedCalls > 0 - ? `Executed ${successfulCalls}/${results.length} tools successfully. ${failedCalls} failed.` - : `All ${successfulCalls} tools executed successfully.\n\nKeep using the batch tool for optimal performance in your next response!` - - return { - title: `Batch execution (${successfulCalls}/${results.length} successful)`, - output: outputMessage, - attachments: results.filter((result) => result.success).flatMap((r) => r.result.attachments ?? []), - metadata: { - totalCalls: results.length, - successful: successfulCalls, - failed: failedCalls, - tools: params.tool_calls.map((c) => c.tool), - details: results.map((r) => ({ tool: r.tool, success: r.success })), - }, - } - }, - } -}) diff --git a/packages/opencode/src/tool/batch.txt b/packages/opencode/src/tool/batch.txt deleted file mode 100644 index 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/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..72911051e0ea 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -4,18 +4,15 @@ import { BashTool } from "./bash" import { EditTool } from "./edit" import { GlobTool } from "./glob" import { GrepTool } from "./grep" -import { BatchTool } from "./batch" import { ReadTool } from "./read" -import { TaskTool } from "./task" +import { TaskDescription, TaskTool } from "./task" import { TodoWriteTool } from "./todo" import { WebFetchTool } from "./webfetch" import { WriteTool } from "./write" import { InvalidTool } from "./invalid" -import { SkillTool } from "./skill" -import type { Agent } from "../agent/agent" +import { SkillDescription, SkillTool } from "./skill" import { Tool } from "./tool" import { Config } from "../config/config" -import path from "path" import { type ToolContext as PluginToolContext, type ToolDefinition } from "@opencode-ai/plugin" import z from "zod" import { Plugin } from "../plugin" @@ -28,6 +25,7 @@ import { LspTool } from "./lsp" import { Truncate } from "./truncate" import { ApplyPatchTool } from "./apply_patch" import { Glob } from "../util/glob" +import path from "path" import { pathToFileURL } from "url" import { Effect, Layer, ServiceMap } from "effect" import { InstanceState } from "@/effect/instance-state" @@ -39,24 +37,25 @@ import { LSP } from "../lsp" import { FileTime } from "../file/time" import { Instruction } from "../session/instruction" import { AppFileSystem } from "../filesystem" +import { Agent } from "../agent/agent" export namespace ToolRegistry { const log = Log.create({ service: "tool.registry" }) type State = { - custom: Tool.Info[] + custom: Tool.Def[] + builtin: Tool.Def[] } export interface Interface { readonly ids: () => Effect.Effect - 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 + agent: Agent.Info + }) => Effect.Effect + readonly fromID: (id: string) => Effect.Effect } export class Service extends ServiceMap.Service()("@opencode/ToolRegistry") {} @@ -79,33 +78,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 +131,99 @@ export namespace ToolRegistry { } } - return { custom } + const cfg = yield* config.get() + const question = + ["app", "cli", "desktop"].includes(Flag.OPENCODE_CLIENT) || Flag.OPENCODE_ENABLE_QUESTION_TOOL + + return { + custom, + builtin: yield* Effect.forEach( + [ + InvalidTool, + BashTool, + ReadTool, + GlobTool, + GrepTool, + EditTool, + WriteTool, + TaskTool, + WebFetchTool, + TodoWriteTool, + WebSearchTool, + CodeSearchTool, + SkillTool, + ApplyPatchTool, + ...(question ? [QuestionTool] : []), + ...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [LspTool] : []), + ...(Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE && Flag.OPENCODE_CLIENT === "cli" ? [PlanExitTool] : []), + ], + build, + { concurrency: "unbounded" }, + ), + } }), ) - const invalid = yield* build(InvalidTool) - const ask = yield* build(QuestionTool) - const bash = yield* build(BashTool) - const read = yield* build(ReadTool) - const glob = yield* build(GlobTool) - const grep = yield* build(GrepTool) - const edit = yield* build(EditTool) - const write = yield* build(WriteTool) - const task = yield* build(TaskTool) - const fetch = yield* build(WebFetchTool) - const todo = yield* build(TodoWriteTool) - const search = yield* build(WebSearchTool) - const code = yield* build(CodeSearchTool) - const skill = yield* build(SkillTool) - const patch = yield* build(ApplyPatchTool) - const lsp = yield* build(LspTool) - const batch = yield* build(BatchTool) - const plan = yield* build(PlanExitTool) - - const all = Effect.fn("ToolRegistry.all")(function* (custom: Tool.Info[]) { - const cfg = yield* config.get() - const question = ["app", "cli", "desktop"].includes(Flag.OPENCODE_CLIENT) || Flag.OPENCODE_ENABLE_QUESTION_TOOL + const all: Interface["all"] = Effect.fn("ToolRegistry.all")(function* () { + const s = yield* InstanceState.get(state) + return [...s.builtin, ...s.custom] as Tool.Def[] + }) - return [ - invalid, - ...(question ? [ask] : []), - bash, - read, - glob, - grep, - edit, - write, - task, - fetch, - todo, - search, - code, - skill, - patch, - ...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [lsp] : []), - ...(cfg.experimental?.batch_tool === true ? [batch] : []), - ...(Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE && Flag.OPENCODE_CLIENT === "cli" ? [plan] : []), - ...custom, - ] + const fromID: Interface["fromID"] = Effect.fn("ToolRegistry.fromID")(function* (id: string) { + const tools = yield* all() + const match = tools.find((tool) => tool.id === id) + if (!match) return yield* Effect.die(`Tool not found: ${id}`) + return match }) - const ids = Effect.fn("ToolRegistry.ids")(function* () { - const s = yield* InstanceState.get(state) - const tools = yield* all(s.custom) - return tools.map((t) => t.id) + const ids: Interface["ids"] = Effect.fn("ToolRegistry.ids")(function* () { + return (yield* all()).map((tool) => tool.id) }) - const tools = Effect.fn("ToolRegistry.tools")(function* ( - model: { providerID: ProviderID; modelID: ModelID }, - agent?: Agent.Info, - ) { - const s = yield* InstanceState.get(state) - const allTools = yield* all(s.custom) - const filtered = allTools.filter((tool) => { - if (tool.id === "codesearch" || tool.id === "websearch") { - return model.providerID === ProviderID.opencode || Flag.OPENCODE_ENABLE_EXA + const tools: Interface["tools"] = Effect.fn("ToolRegistry.tools")(function* (input) { + const filtered = (yield* all()).filter((tool) => { + if (tool.id === CodeSearchTool.id || tool.id === WebSearchTool.id) { + return input.providerID === ProviderID.opencode || Flag.OPENCODE_ENABLE_EXA } const usePatch = !!Env.get("OPENCODE_E2E_LLM_URL") || - (model.modelID.includes("gpt-") && !model.modelID.includes("oss") && !model.modelID.includes("gpt-4")) - if (tool.id === "apply_patch") return usePatch - if (tool.id === "edit" || tool.id === "write") return !usePatch + (input.modelID.includes("gpt-") && !input.modelID.includes("oss") && !input.modelID.includes("gpt-4")) + if (tool.id === ApplyPatchTool.id) return usePatch + if (tool.id === EditTool.id || tool.id === WriteTool.id) return !usePatch return true }) + return yield* Effect.forEach( filtered, - Effect.fnUntraced(function* (tool: Tool.Info) { + Effect.fnUntraced(function* (tool: Tool.Def) { using _ = log.time(tool.id) - const next = yield* Effect.promise(() => tool.init({ agent })) const output = { - description: next.description, - parameters: next.parameters, + description: tool.description, + parameters: tool.parameters, } yield* plugin.trigger("tool.definition", { toolID: tool.id }, output) return { id: tool.id, - description: output.description, + description: [ + output.description, + // TODO: remove this hack + tool.id === TaskTool.id ? yield* TaskDescription(input.agent) : undefined, + tool.id === SkillTool.id ? yield* SkillDescription(input.agent) : undefined, + ] + .filter(Boolean) + .join("\n"), parameters: output.parameters, - execute: next.execute, - formatValidationError: next.formatValidationError, + execute: tool.execute, + formatValidationError: tool.formatValidationError, } }), { concurrency: "unbounded" }, ) }) - return Service.of({ ids, named: { task, read }, tools }) + return Service.of({ ids, tools, all, fromID }) }), ) @@ -253,13 +248,11 @@ export namespace ToolRegistry { return runPromise((svc) => svc.ids()) } - export async function tools( - model: { - providerID: ProviderID - modelID: ModelID - }, - agent?: Agent.Info, - ): Promise<(Tool.Def & { id: string })[]> { - return runPromise((svc) => svc.tools(model, agent)) + export async function tools(input: { + providerID: ProviderID + modelID: ModelID + agent: Agent.Info + }): Promise<(Tool.Def & { id: string })[]> { + return runPromise((svc) => svc.tools(input)) } } diff --git a/packages/opencode/src/tool/skill.ts b/packages/opencode/src/tool/skill.ts index 17016b06f807..276f3931d012 100644 --- a/packages/opencode/src/tool/skill.ts +++ b/packages/opencode/src/tool/skill.ts @@ -1,3 +1,4 @@ +import { Effect } from "effect" import path from "path" import { pathToFileURL } from "url" import z from "zod" @@ -6,8 +7,12 @@ import { Skill } from "../skill" import { Ripgrep } from "../file/ripgrep" import { iife } from "@/util/iife" -export const SkillTool = Tool.define("skill", async (ctx) => { - const list = await Skill.available(ctx?.agent) +const Parameters = z.object({ + name: z.string().describe("The name of the skill from available_skills"), +}) + +export const SkillTool = Tool.define("skill", async () => { + const list = await Skill.available() const description = list.length === 0 @@ -27,20 +32,10 @@ export const SkillTool = Tool.define("skill", async (ctx) => { Skill.fmt(list, { verbose: false }), ].join("\n") - const examples = list - .map((skill) => `'${skill.name}'`) - .slice(0, 3) - .join(", ") - const hint = examples.length > 0 ? ` (e.g., ${examples}, ...)` : "" - - const parameters = z.object({ - name: z.string().describe(`The name of the skill from available_skills${hint}`), - }) - return { description, - parameters, - async execute(params: z.infer, ctx) { + parameters: Parameters, + async execute(params: z.infer, 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 `` 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 af130a70d919..07e779f5bd56 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -4,47 +4,37 @@ import z from "zod" import { Session } from "../session" import { SessionID, MessageID } from "../session/schema" import { MessageV2 } from "../session/message-v2" -import { Identifier } from "../id/id" import { Agent } from "../agent/agent" import { SessionPrompt } from "../session/prompt" import { iife } from "@/util/iife" import { defer } from "@/util/defer" import { Config } from "../config/config" import { Permission } from "@/permission" +import { Effect } from "effect" -const parameters = z.object({ - description: z.string().describe("A short (3-5 words) description of the task"), - prompt: z.string().describe("The task for the agent to perform"), - subagent_type: z.string().describe("The type of specialized agent to use for this task"), - task_id: z - .string() - .describe( - "This should only be set if you mean to resume a previous task (you can pass a prior task_id and the task will continue the same subagent session as before instead of creating a fresh one)", - ) - .optional(), - command: z.string().describe("The command that triggered this task").optional(), -}) - -export const TaskTool = Tool.define("task", async (ctx) => { +export const TaskTool = Tool.define("task", async () => { const agents = await Agent.list().then((x) => x.filter((a) => a.mode !== "primary")) + const list = agents.toSorted((a, b) => a.name.localeCompare(b.name)) + const agentList = list + .map((a) => `- ${a.name}: ${a.description ?? "This subagent should only be called manually by the user."}`) + .join("\n") + const description = [`Available agent types and the tools they have access to:`, agentList].join("\n") - // Filter agents by permissions if agent provided - const caller = ctx?.agent - const accessibleAgents = caller - ? agents.filter((a) => Permission.evaluate("task", a.name, caller.permission).action !== "deny") - : agents - const list = accessibleAgents.toSorted((a, b) => a.name.localeCompare(b.name)) - - const description = DESCRIPTION.replace( - "{agents}", - list - .map((a) => `- ${a.name}: ${a.description ?? "This subagent should only be called manually by the user."}`) - .join("\n"), - ) return { description, - parameters, - async execute(params: z.infer, ctx) { + parameters: z.object({ + description: z.string().describe("A short (3-5 words) description of the task"), + prompt: z.string().describe("The task for the agent to perform"), + subagent_type: z.string().describe("The type of specialized agent to use for this task"), + task_id: z + .string() + .describe( + "This should only be set if you mean to resume a previous task (you can pass a prior task_id and the task will continue the same subagent session as before instead of creating a fresh one)", + ) + .optional(), + command: z.string().describe("The command that triggered this task").optional(), + }), + async execute(params, ctx) { const config = await Config.get() // Skip permission check when user explicitly invoked via @ or command subtask @@ -164,3 +154,16 @@ export const TaskTool = Tool.define("task", async (ctx) => { }, } }) + +export const TaskDescription: Tool.DynamicDescription = (agent) => + Effect.gen(function* () { + const agents = yield* Effect.promise(() => Agent.list().then((x) => x.filter((a) => a.mode !== "primary"))) + const accessibleAgents = agents.filter( + (a) => Permission.evaluate("task", a.name, agent.permission).action !== "deny", + ) + const list = accessibleAgents.toSorted((a, b) => a.name.localeCompare(b.name)) + const description = list + .map((a) => `- ${a.name}: ${a.description ?? "This subagent should only be called manually by the user."}`) + .join("\n") + return [`Available agent types and the tools they have access to:`, description].join("\n") + }) diff --git a/packages/opencode/src/tool/task.txt b/packages/opencode/src/tool/task.txt index 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/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..6d129f4271b4 100644 --- a/packages/opencode/src/tool/tool.ts +++ b/packages/opencode/src/tool/tool.ts @@ -1,19 +1,18 @@ import z from "zod" import { Effect } from "effect" import type { MessageV2 } from "../session/message-v2" -import type { Agent } from "../agent/agent" import type { Permission } from "../permission" import type { SessionID, MessageID } from "../session/schema" import { Truncate } from "./truncate" +import { Agent } from "@/agent/agent" export namespace Tool { interface Metadata { [key: string]: any } - export interface InitContext { - agent?: Agent.Info - } + // TODO: remove this hack + export type DynamicDescription = (agent: Agent.Info) => Effect.Effect export type Context = { sessionID: SessionID @@ -26,7 +25,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 +41,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 +62,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 +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( id: string, - init: ((ctx?: InitContext) => Promise>) | Def, + init: (() => Promise>) | DefWithoutID, ): Info { return { id, @@ -105,8 +110,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/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", 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 aae48a30ab3f..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,16 +29,16 @@ describe("tool.task", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const build = await Agent.get("build") - const first = await TaskTool.init({ agent: build }) - const second = await TaskTool.init({ agent: build }) + const agent = { name: "build", mode: "primary" as const, permission: [], options: {} } + const first = await Effect.runPromise(TaskDescription(agent)) + const second = await Effect.runPromise(TaskDescription(agent)) - expect(first.description).toBe(second.description) + expect(first).toBe(second) - const alpha = first.description.indexOf("- alpha: Alpha agent") - const explore = first.description.indexOf("- explore:") - const general = first.description.indexOf("- general:") - const zebra = first.description.indexOf("- zebra: Zebra agent") + const alpha = first.indexOf("- alpha: Alpha agent") + const explore = first.indexOf("- explore:") + const general = first.indexOf("- general:") + const zebra = first.indexOf("- zebra: Zebra agent") expect(alpha).toBeGreaterThan(-1) expect(explore).toBeGreaterThan(alpha) diff --git a/packages/opencode/test/tool/tool-define.test.ts b/packages/opencode/test/tool/tool-define.test.ts index 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") - }) }) 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,