From 56dae61c6e67f44fa626ac105e1c840a24c81501 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 15 Apr 2026 23:49:51 -0400 Subject: [PATCH] feat: unwrap tool namespaces to flat exports + barrel --- packages/opencode/src/agent/agent.ts | 2 +- packages/opencode/src/cli/cmd/debug/agent.ts | 2 +- packages/opencode/src/cli/cmd/run.ts | 2 +- .../src/cli/cmd/tui/routes/session/index.tsx | 2 +- packages/opencode/src/effect/app-runtime.ts | 4 +- .../src/server/instance/experimental.ts | 2 +- packages/opencode/src/session/prompt.ts | 6 +- packages/opencode/src/tool/apply_patch.ts | 2 +- packages/opencode/src/tool/bash.ts | 4 +- packages/opencode/src/tool/codesearch.ts | 2 +- packages/opencode/src/tool/edit.ts | 2 +- .../opencode/src/tool/external-directory.ts | 2 +- packages/opencode/src/tool/glob.ts | 2 +- packages/opencode/src/tool/grep.ts | 2 +- packages/opencode/src/tool/index.ts | 3 + packages/opencode/src/tool/invalid.ts | 2 +- packages/opencode/src/tool/lsp.ts | 2 +- packages/opencode/src/tool/multiedit.ts | 2 +- packages/opencode/src/tool/plan.ts | 2 +- packages/opencode/src/tool/question.ts | 2 +- packages/opencode/src/tool/read.ts | 2 +- packages/opencode/src/tool/registry.ts | 536 +++++++++--------- packages/opencode/src/tool/skill.ts | 2 +- packages/opencode/src/tool/task.ts | 2 +- packages/opencode/src/tool/todo.ts | 2 +- packages/opencode/src/tool/tool.ts | 250 ++++---- packages/opencode/src/tool/truncate.ts | 254 ++++----- packages/opencode/src/tool/webfetch.ts | 2 +- packages/opencode/src/tool/websearch.ts | 2 +- packages/opencode/src/tool/write.ts | 2 +- packages/opencode/test/agent/agent.test.ts | 8 +- .../test/session/prompt-effect.test.ts | 4 +- .../test/session/snapshot-tool-race.test.ts | 4 +- .../opencode/test/tool/apply_patch.test.ts | 2 +- packages/opencode/test/tool/bash.test.ts | 2 +- packages/opencode/test/tool/edit.test.ts | 2 +- .../test/tool/external-directory.test.ts | 2 +- packages/opencode/test/tool/glob.test.ts | 2 +- packages/opencode/test/tool/grep.test.ts | 2 +- packages/opencode/test/tool/question.test.ts | 2 +- packages/opencode/test/tool/read.test.ts | 4 +- packages/opencode/test/tool/registry.test.ts | 2 +- packages/opencode/test/tool/skill.test.ts | 4 +- packages/opencode/test/tool/task.test.ts | 4 +- .../opencode/test/tool/tool-define.test.ts | 4 +- .../opencode/test/tool/truncation.test.ts | 2 +- packages/opencode/test/tool/webfetch.test.ts | 2 +- packages/opencode/test/tool/write.test.ts | 4 +- 48 files changed, 578 insertions(+), 581 deletions(-) create mode 100644 packages/opencode/src/tool/index.ts diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index b027c8c945ec..f7e3a351548d 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -4,7 +4,7 @@ import { Provider } from "../provider" import { ModelID, ProviderID } from "../provider/schema" import { generateObject, streamObject, type ModelMessage } from "ai" import { Instance } from "../project/instance" -import { Truncate } from "../tool/truncate" +import { Truncate } from "../tool" import { Auth } from "../auth" import { ProviderTransform } from "../provider/transform" diff --git a/packages/opencode/src/cli/cmd/debug/agent.ts b/packages/opencode/src/cli/cmd/debug/agent.ts index 29d6ace598c6..10b6d5c9e2b0 100644 --- a/packages/opencode/src/cli/cmd/debug/agent.ts +++ b/packages/opencode/src/cli/cmd/debug/agent.ts @@ -6,7 +6,7 @@ import { Provider } from "../../../provider" import { Session } from "../../../session" import type { MessageV2 } from "../../../session/message-v2" import { MessageID, PartID } from "../../../session/schema" -import { ToolRegistry } from "../../../tool/registry" +import { ToolRegistry } from "../../../tool" import { Instance } from "../../../project/instance" import { Permission } from "../../../permission" import { iife } from "../../../util/iife" diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index da7237237019..0874beee16c8 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -12,7 +12,7 @@ import { Server } from "../../server/server" import { Provider } from "../../provider" import { Agent } from "../../agent/agent" import { Permission } from "../../permission" -import { Tool } from "../../tool/tool" +import { Tool } from "../../tool" import { GlobTool } from "../../tool/glob" import { GrepTool } from "../../tool/grep" import { ReadTool } from "../../tool/read" diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 2ea936c898bd..0cb295a976f5 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -34,7 +34,7 @@ import type { } from "@opencode-ai/sdk/v2" import { useLocal } from "@tui/context/local" import { Locale } from "@/util" -import type { Tool } from "@/tool/tool" +import type { Tool } from "@/tool" import type { ReadTool } from "@/tool/read" import type { WriteTool } from "@/tool/write" import { BashTool } from "@/tool/bash" diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts index 0b76e96a84ae..60bbfe0ef5f4 100644 --- a/packages/opencode/src/effect/app-runtime.ts +++ b/packages/opencode/src/effect/app-runtime.ts @@ -37,8 +37,8 @@ import { LSP } from "@/lsp" import { MCP } from "@/mcp" import { McpAuth } from "@/mcp/auth" import { Command } from "@/command" -import { Truncate } from "@/tool/truncate" -import { ToolRegistry } from "@/tool/registry" +import { Truncate } from "@/tool" +import { ToolRegistry } from "@/tool" import { Format } from "@/format" import { Project } from "@/project" import { Vcs } from "@/project" diff --git a/packages/opencode/src/server/instance/experimental.ts b/packages/opencode/src/server/instance/experimental.ts index 610d67df08e4..fe80173a8bce 100644 --- a/packages/opencode/src/server/instance/experimental.ts +++ b/packages/opencode/src/server/instance/experimental.ts @@ -2,7 +2,7 @@ import { Hono } from "hono" import { describeRoute, validator, resolver } from "hono-openapi" import z from "zod" import { ProviderID, ModelID } from "../../provider/schema" -import { ToolRegistry } from "../../tool/registry" +import { ToolRegistry } from "../../tool" import { Worktree } from "../../worktree" import { Instance } from "../../project/instance" import { Project } from "../../project" diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 65fc7c8c7058..44073c85011d 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -19,7 +19,7 @@ import { Plugin } from "../plugin" import PROMPT_PLAN from "../session/prompt/plan.txt" import BUILD_SWITCH from "../session/prompt/build-switch.txt" import MAX_STEPS from "../session/prompt/max-steps.txt" -import { ToolRegistry } from "../tool/registry" +import { ToolRegistry } from "../tool" import { MCP } from "../mcp" import { LSP } from "../lsp" import { FileTime } from "../file/time" @@ -34,13 +34,13 @@ import { ConfigMarkdown } from "../config" import { SessionSummary } from "./summary" import { NamedError } from "@opencode-ai/shared/util/error" import { SessionProcessor } from "./processor" -import { Tool } from "@/tool/tool" +import { Tool } from "@/tool" import { Permission } from "@/permission" import { SessionStatus } from "./status" import { LLM } from "./llm" import { Shell } from "@/shell/shell" import { AppFileSystem } from "@opencode-ai/shared/filesystem" -import { Truncate } from "@/tool/truncate" +import { Truncate } from "@/tool" import { decodeDataUrl } from "@/util/data-url" import { Process } from "@/util" import { Cause, Effect, Exit, Layer, Option, Scope, Context } from "effect" diff --git a/packages/opencode/src/tool/apply_patch.ts b/packages/opencode/src/tool/apply_patch.ts index b9877d8fec8b..4368b692f274 100644 --- a/packages/opencode/src/tool/apply_patch.ts +++ b/packages/opencode/src/tool/apply_patch.ts @@ -1,7 +1,7 @@ import z from "zod" import * as path from "path" import { Effect } from "effect" -import { Tool } from "./tool" +import * as Tool from "./tool" import { Bus } from "../bus" import { FileWatcher } from "../file/watcher" import { Instance } from "../project/instance" diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index 1edd754143c2..6260b22216e2 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -1,7 +1,7 @@ import z from "zod" import os from "os" import { createWriteStream } from "node:fs" -import { Tool } from "./tool" +import * as Tool from "./tool" import path from "path" import DESCRIPTION from "./bash.txt" import { Log } from "../util" @@ -15,7 +15,7 @@ import { Flag } from "@/flag/flag" import { Shell } from "@/shell/shell" import { BashArity } from "@/permission/arity" -import { Truncate } from "./truncate" +import * as Truncate from "./truncate" import { Plugin } from "@/plugin" import { Effect, Stream } from "effect" import { ChildProcess } from "effect/unstable/process" diff --git a/packages/opencode/src/tool/codesearch.ts b/packages/opencode/src/tool/codesearch.ts index d4d5779bf306..ac9961e25062 100644 --- a/packages/opencode/src/tool/codesearch.ts +++ b/packages/opencode/src/tool/codesearch.ts @@ -1,7 +1,7 @@ import z from "zod" import { Effect } from "effect" import { HttpClient } from "effect/unstable/http" -import { Tool } from "./tool" +import * as Tool from "./tool" import * as McpExa from "./mcp-exa" import DESCRIPTION from "./codesearch.txt" diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts index 2303618a0b6f..62b96cba820c 100644 --- a/packages/opencode/src/tool/edit.ts +++ b/packages/opencode/src/tool/edit.ts @@ -6,7 +6,7 @@ import z from "zod" import * as path from "path" import { Effect } from "effect" -import { Tool } from "./tool" +import * as Tool from "./tool" import { LSP } from "../lsp" import { createTwoFilesPatch, diffLines } from "diff" import DESCRIPTION from "./edit.txt" diff --git a/packages/opencode/src/tool/external-directory.ts b/packages/opencode/src/tool/external-directory.ts index 810206f817b8..88b73da50968 100644 --- a/packages/opencode/src/tool/external-directory.ts +++ b/packages/opencode/src/tool/external-directory.ts @@ -2,7 +2,7 @@ import path from "path" import { Effect } from "effect" import { EffectLogger } from "@/effect" import { InstanceState } from "@/effect" -import type { Tool } from "./tool" +import type * as Tool from "./tool" import { Instance } from "../project/instance" import { AppFileSystem } from "@opencode-ai/shared/filesystem" diff --git a/packages/opencode/src/tool/glob.ts b/packages/opencode/src/tool/glob.ts index 0a0a8f1e250a..673bb9cc8fca 100644 --- a/packages/opencode/src/tool/glob.ts +++ b/packages/opencode/src/tool/glob.ts @@ -7,7 +7,7 @@ import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { Ripgrep } from "../file/ripgrep" import { assertExternalDirectoryEffect } from "./external-directory" import DESCRIPTION from "./glob.txt" -import { Tool } from "./tool" +import * as Tool from "./tool" export const GlobTool = Tool.define( "glob", diff --git a/packages/opencode/src/tool/grep.ts b/packages/opencode/src/tool/grep.ts index b6b4a063f05c..caa75edad53e 100644 --- a/packages/opencode/src/tool/grep.ts +++ b/packages/opencode/src/tool/grep.ts @@ -6,7 +6,7 @@ import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { Ripgrep } from "../file/ripgrep" import { assertExternalDirectoryEffect } from "./external-directory" import DESCRIPTION from "./grep.txt" -import { Tool } from "./tool" +import * as Tool from "./tool" const MAX_LINE_LENGTH = 2000 diff --git a/packages/opencode/src/tool/index.ts b/packages/opencode/src/tool/index.ts new file mode 100644 index 000000000000..5b2463b5077e --- /dev/null +++ b/packages/opencode/src/tool/index.ts @@ -0,0 +1,3 @@ +export * as Truncate from "./truncate" +export * as ToolRegistry from "./registry" +export * as Tool from "./tool" diff --git a/packages/opencode/src/tool/invalid.ts b/packages/opencode/src/tool/invalid.ts index b9794ed5fdaa..aca3618b6d04 100644 --- a/packages/opencode/src/tool/invalid.ts +++ b/packages/opencode/src/tool/invalid.ts @@ -1,6 +1,6 @@ import z from "zod" import { Effect } from "effect" -import { Tool } from "./tool" +import * as Tool from "./tool" export const InvalidTool = Tool.define( "invalid", diff --git a/packages/opencode/src/tool/lsp.ts b/packages/opencode/src/tool/lsp.ts index 36cab3c1c338..263bfe81d2fc 100644 --- a/packages/opencode/src/tool/lsp.ts +++ b/packages/opencode/src/tool/lsp.ts @@ -1,6 +1,6 @@ import z from "zod" import { Effect } from "effect" -import { Tool } from "./tool" +import * as Tool from "./tool" import path from "path" import { LSP } from "../lsp" import DESCRIPTION from "./lsp.txt" diff --git a/packages/opencode/src/tool/multiedit.ts b/packages/opencode/src/tool/multiedit.ts index 449df33430eb..004d3c870dd5 100644 --- a/packages/opencode/src/tool/multiedit.ts +++ b/packages/opencode/src/tool/multiedit.ts @@ -1,6 +1,6 @@ import z from "zod" import { Effect } from "effect" -import { Tool } from "./tool" +import * as Tool from "./tool" import { EditTool } from "./edit" import DESCRIPTION from "./multiedit.txt" import path from "path" diff --git a/packages/opencode/src/tool/plan.ts b/packages/opencode/src/tool/plan.ts index cc52c2abde83..fd7276e09cc7 100644 --- a/packages/opencode/src/tool/plan.ts +++ b/packages/opencode/src/tool/plan.ts @@ -1,7 +1,7 @@ import z from "zod" import path from "path" import { Effect } from "effect" -import { Tool } from "./tool" +import * as Tool from "./tool" import { Question } from "../question" import { Session } from "../session" import { MessageV2 } from "../session/message-v2" diff --git a/packages/opencode/src/tool/question.ts b/packages/opencode/src/tool/question.ts index 50e4b1c51124..e5bb33aa69fb 100644 --- a/packages/opencode/src/tool/question.ts +++ b/packages/opencode/src/tool/question.ts @@ -1,6 +1,6 @@ import z from "zod" import { Effect } from "effect" -import { Tool } from "./tool" +import * as Tool from "./tool" import { Question } from "../question" import DESCRIPTION from "./question.txt" diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index 4dc984d0ee1b..c6d1461cdfa8 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -4,7 +4,7 @@ import { createReadStream } from "fs" import { open } from "fs/promises" import * as path from "path" import { createInterface } from "readline" -import { Tool } from "./tool" +import * as Tool from "./tool" import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { LSP } from "../lsp" import { FileTime } from "../file/time" diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 80115884d95a..fa442fd3a4e4 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -12,7 +12,7 @@ import { WebFetchTool } from "./webfetch" import { WriteTool } from "./write" import { InvalidTool } from "./invalid" import { SkillTool } from "./skill" -import { Tool } from "./tool" +import * as Tool from "./tool" import { Config } from "../config" import { type ToolContext as PluginToolContext, type ToolDefinition } from "@opencode-ai/plugin" import z from "zod" @@ -24,7 +24,7 @@ import { CodeSearchTool } from "./codesearch" import { Flag } from "@/flag/flag" import { Log } from "@/util" import { LspTool } from "./lsp" -import { Truncate } from "./truncate" +import * as Truncate from "./truncate" import { ApplyPatchTool } from "./apply_patch" import { Glob } from "@opencode-ai/shared/util/glob" import path from "path" @@ -47,299 +47,297 @@ import { Agent } from "../agent/agent" import { Skill } from "../skill" import { Permission } from "@/permission" -export namespace ToolRegistry { - const log = Log.create({ service: "tool.registry" }) +const log = Log.create({ service: "tool.registry" }) - type TaskDef = Tool.InferDef - type ReadDef = Tool.InferDef +type TaskDef = Tool.InferDef +type ReadDef = Tool.InferDef - type State = { - custom: Tool.Def[] - builtin: Tool.Def[] - task: TaskDef - read: ReadDef - } +type State = { + custom: Tool.Def[] + builtin: Tool.Def[] + task: TaskDef + read: ReadDef +} - export interface Interface { - readonly ids: () => Effect.Effect - readonly all: () => Effect.Effect - readonly named: () => Effect.Effect<{ task: TaskDef; read: ReadDef }> - readonly tools: (model: { - providerID: ProviderID - modelID: ModelID - agent: Agent.Info - }) => Effect.Effect - } +export interface Interface { + readonly ids: () => Effect.Effect + readonly all: () => Effect.Effect + readonly named: () => Effect.Effect<{ task: TaskDef; read: ReadDef }> + readonly tools: (model: { + providerID: ProviderID + modelID: ModelID + agent: Agent.Info + }) => Effect.Effect +} - export class Service extends Context.Service()("@opencode/ToolRegistry") {} +export class Service extends Context.Service()("@opencode/ToolRegistry") {} - export const layer: Layer.Layer< - Service, - never, - | Config.Service - | Plugin.Service - | Question.Service - | Todo.Service - | Agent.Service - | Skill.Service - | Session.Service - | Provider.Service - | LSP.Service - | FileTime.Service - | Instruction.Service - | AppFileSystem.Service - | Bus.Service - | HttpClient.HttpClient - | ChildProcessSpawner - | Ripgrep.Service - | Format.Service - | Truncate.Service - > = Layer.effect( - Service, - Effect.gen(function* () { - const config = yield* Config.Service - const plugin = yield* Plugin.Service - const agents = yield* Agent.Service - const skill = yield* Skill.Service - const truncate = yield* Truncate.Service +export const layer: Layer.Layer< + Service, + never, + | Config.Service + | Plugin.Service + | Question.Service + | Todo.Service + | Agent.Service + | Skill.Service + | Session.Service + | Provider.Service + | LSP.Service + | FileTime.Service + | Instruction.Service + | AppFileSystem.Service + | Bus.Service + | HttpClient.HttpClient + | ChildProcessSpawner + | Ripgrep.Service + | Format.Service + | Truncate.Service +> = Layer.effect( + Service, + Effect.gen(function* () { + const config = yield* Config.Service + const plugin = yield* Plugin.Service + const agents = yield* Agent.Service + const skill = yield* Skill.Service + const truncate = yield* Truncate.Service - const invalid = yield* InvalidTool - const task = yield* TaskTool - const read = yield* ReadTool - const question = yield* QuestionTool - const todo = yield* TodoWriteTool - const lsptool = yield* LspTool - const plan = yield* PlanExitTool - const webfetch = yield* WebFetchTool - const websearch = yield* WebSearchTool - const bash = yield* BashTool - const codesearch = yield* CodeSearchTool - const globtool = yield* GlobTool - const writetool = yield* WriteTool - const edit = yield* EditTool - const greptool = yield* GrepTool - const patchtool = yield* ApplyPatchTool - const skilltool = yield* SkillTool - const agent = yield* Agent.Service + const invalid = yield* InvalidTool + const task = yield* TaskTool + const read = yield* ReadTool + const question = yield* QuestionTool + const todo = yield* TodoWriteTool + const lsptool = yield* LspTool + const plan = yield* PlanExitTool + const webfetch = yield* WebFetchTool + const websearch = yield* WebSearchTool + const bash = yield* BashTool + const codesearch = yield* CodeSearchTool + const globtool = yield* GlobTool + const writetool = yield* WriteTool + const edit = yield* EditTool + const greptool = yield* GrepTool + const patchtool = yield* ApplyPatchTool + const skilltool = yield* SkillTool + const agent = yield* Agent.Service - const state = yield* InstanceState.make( - Effect.fn("ToolRegistry.state")(function* (ctx) { - const custom: Tool.Def[] = [] + const state = yield* InstanceState.make( + Effect.fn("ToolRegistry.state")(function* (ctx) { + const custom: Tool.Def[] = [] - function fromPlugin(id: string, def: ToolDefinition): Tool.Def { - return { - id, - parameters: z.object(def.args), - description: def.description, - execute: (args, toolCtx) => - Effect.gen(function* () { - const pluginCtx: PluginToolContext = { - ...toolCtx, - ask: (req) => toolCtx.ask(req), - directory: ctx.directory, - worktree: ctx.worktree, - } - const result = yield* Effect.promise(() => def.execute(args as any, pluginCtx)) - const info = yield* agent.get(toolCtx.agent) - const out = yield* truncate.output(result, {}, info) - return { - title: "", - output: out.truncated ? out.content : result, - metadata: { - truncated: out.truncated, - outputPath: out.truncated ? out.outputPath : undefined, - }, - } - }), - } + function fromPlugin(id: string, def: ToolDefinition): Tool.Def { + return { + id, + parameters: z.object(def.args), + description: def.description, + execute: (args, toolCtx) => + Effect.gen(function* () { + const pluginCtx: PluginToolContext = { + ...toolCtx, + ask: (req) => toolCtx.ask(req), + directory: ctx.directory, + worktree: ctx.worktree, + } + const result = yield* Effect.promise(() => def.execute(args as any, pluginCtx)) + const info = yield* agent.get(toolCtx.agent) + const out = yield* truncate.output(result, {}, info) + return { + title: "", + output: out.truncated ? out.content : result, + metadata: { + truncated: out.truncated, + outputPath: out.truncated ? out.outputPath : undefined, + }, + } + }), } + } - const dirs = yield* config.directories() - const matches = dirs.flatMap((dir) => - Glob.scanSync("{tool,tools}/*.{js,ts}", { cwd: dir, absolute: true, dot: true, symlink: true }), + const dirs = yield* config.directories() + const matches = dirs.flatMap((dir) => + Glob.scanSync("{tool,tools}/*.{js,ts}", { cwd: dir, absolute: true, dot: true, symlink: true }), + ) + if (matches.length) yield* config.waitForDependencies() + for (const match of matches) { + const namespace = path.basename(match, path.extname(match)) + const mod = yield* Effect.promise( + () => import(process.platform === "win32" ? match : pathToFileURL(match).href), ) - if (matches.length) yield* config.waitForDependencies() - for (const match of matches) { - const namespace = path.basename(match, path.extname(match)) - const mod = yield* Effect.promise( - () => import(process.platform === "win32" ? match : pathToFileURL(match).href), - ) - for (const [id, def] of Object.entries(mod)) { - custom.push(fromPlugin(id === "default" ? namespace : `${namespace}_${id}`, def)) - } + for (const [id, def] of Object.entries(mod)) { + custom.push(fromPlugin(id === "default" ? namespace : `${namespace}_${id}`, def)) } + } - const plugins = yield* plugin.list() - for (const p of plugins) { - for (const [id, def] of Object.entries(p.tool ?? {})) { - custom.push(fromPlugin(id, def)) - } + const plugins = yield* plugin.list() + for (const p of plugins) { + for (const [id, def] of Object.entries(p.tool ?? {})) { + custom.push(fromPlugin(id, def)) } + } - yield* config.get() - const questionEnabled = - ["app", "cli", "desktop"].includes(Flag.OPENCODE_CLIENT) || Flag.OPENCODE_ENABLE_QUESTION_TOOL + yield* config.get() + const questionEnabled = + ["app", "cli", "desktop"].includes(Flag.OPENCODE_CLIENT) || Flag.OPENCODE_ENABLE_QUESTION_TOOL - const tool = yield* Effect.all({ - invalid: Tool.init(invalid), - bash: Tool.init(bash), - read: Tool.init(read), - glob: Tool.init(globtool), - grep: Tool.init(greptool), - edit: Tool.init(edit), - write: Tool.init(writetool), - task: Tool.init(task), - fetch: Tool.init(webfetch), - todo: Tool.init(todo), - search: Tool.init(websearch), - code: Tool.init(codesearch), - skill: Tool.init(skilltool), - patch: Tool.init(patchtool), - question: Tool.init(question), - lsp: Tool.init(lsptool), - plan: Tool.init(plan), - }) + const tool = yield* Effect.all({ + invalid: Tool.init(invalid), + bash: Tool.init(bash), + read: Tool.init(read), + glob: Tool.init(globtool), + grep: Tool.init(greptool), + edit: Tool.init(edit), + write: Tool.init(writetool), + task: Tool.init(task), + fetch: Tool.init(webfetch), + todo: Tool.init(todo), + search: Tool.init(websearch), + code: Tool.init(codesearch), + skill: Tool.init(skilltool), + patch: Tool.init(patchtool), + question: Tool.init(question), + lsp: Tool.init(lsptool), + plan: Tool.init(plan), + }) - return { - custom, - builtin: [ - tool.invalid, - ...(questionEnabled ? [tool.question] : []), - tool.bash, - tool.read, - tool.glob, - tool.grep, - tool.edit, - tool.write, - tool.task, - tool.fetch, - tool.todo, - tool.search, - tool.code, - tool.skill, - tool.patch, - ...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [tool.lsp] : []), - ...(Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE && Flag.OPENCODE_CLIENT === "cli" ? [tool.plan] : []), - ], - task: tool.task, - read: tool.read, - } - }), - ) + return { + custom, + builtin: [ + tool.invalid, + ...(questionEnabled ? [tool.question] : []), + tool.bash, + tool.read, + tool.glob, + tool.grep, + tool.edit, + tool.write, + tool.task, + tool.fetch, + tool.todo, + tool.search, + tool.code, + tool.skill, + tool.patch, + ...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [tool.lsp] : []), + ...(Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE && Flag.OPENCODE_CLIENT === "cli" ? [tool.plan] : []), + ], + task: tool.task, + read: tool.read, + } + }), + ) - const all: Interface["all"] = Effect.fn("ToolRegistry.all")(function* () { - const s = yield* InstanceState.get(state) - return [...s.builtin, ...s.custom] as Tool.Def[] - }) + const all: Interface["all"] = Effect.fn("ToolRegistry.all")(function* () { + const s = yield* InstanceState.get(state) + return [...s.builtin, ...s.custom] as Tool.Def[] + }) - const ids: Interface["ids"] = Effect.fn("ToolRegistry.ids")(function* () { - return (yield* all()).map((tool) => tool.id) - }) + const ids: Interface["ids"] = Effect.fn("ToolRegistry.ids")(function* () { + return (yield* all()).map((tool) => tool.id) + }) - const describeSkill = Effect.fn("ToolRegistry.describeSkill")(function* (agent: Agent.Info) { - const list = yield* 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") - }) + const describeSkill = Effect.fn("ToolRegistry.describeSkill")(function* (agent: Agent.Info) { + const list = yield* 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") + }) - const describeTask = Effect.fn("ToolRegistry.describeTask")(function* (agent: Agent.Info) { - const items = (yield* agents.list()).filter((item) => item.mode !== "primary") - const filtered = items.filter( - (item) => Permission.evaluate("task", item.name, agent.permission).action !== "deny", + const describeTask = Effect.fn("ToolRegistry.describeTask")(function* (agent: Agent.Info) { + const items = (yield* agents.list()).filter((item) => item.mode !== "primary") + const filtered = items.filter( + (item) => Permission.evaluate("task", item.name, agent.permission).action !== "deny", + ) + const list = filtered.toSorted((a, b) => a.name.localeCompare(b.name)) + const description = list + .map( + (item) => + `- ${item.name}: ${item.description ?? "This subagent should only be called manually by the user."}`, ) - const list = filtered.toSorted((a, b) => a.name.localeCompare(b.name)) - const description = list - .map( - (item) => - `- ${item.name}: ${item.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") - }) - - 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 - } + .join("\n") + return ["Available agent types and the tools they have access to:", description].join("\n") + }) - const 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 + 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 + } - return true - }) + const 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 yield* Effect.forEach( - filtered, - Effect.fnUntraced(function* (tool: Tool.Def) { - using _ = log.time(tool.id) - const output = { - description: tool.description, - parameters: tool.parameters, - } - yield* plugin.trigger("tool.definition", { toolID: tool.id }, output) - return { - id: tool.id, - description: [ - output.description, - tool.id === TaskTool.id ? yield* describeTask(input.agent) : undefined, - tool.id === SkillTool.id ? yield* describeSkill(input.agent) : undefined, - ] - .filter(Boolean) - .join("\n"), - parameters: output.parameters, - execute: tool.execute, - formatValidationError: tool.formatValidationError, - } - }), - { concurrency: "unbounded" }, - ) + return true }) - const named: Interface["named"] = Effect.fn("ToolRegistry.named")(function* () { - const s = yield* InstanceState.get(state) - return { task: s.task, read: s.read } - }) + return yield* Effect.forEach( + filtered, + Effect.fnUntraced(function* (tool: Tool.Def) { + using _ = log.time(tool.id) + const output = { + description: tool.description, + parameters: tool.parameters, + } + yield* plugin.trigger("tool.definition", { toolID: tool.id }, output) + return { + id: tool.id, + description: [ + output.description, + tool.id === TaskTool.id ? yield* describeTask(input.agent) : undefined, + tool.id === SkillTool.id ? yield* describeSkill(input.agent) : undefined, + ] + .filter(Boolean) + .join("\n"), + parameters: output.parameters, + execute: tool.execute, + formatValidationError: tool.formatValidationError, + } + }), + { concurrency: "unbounded" }, + ) + }) - return Service.of({ ids, all, named, tools }) - }), - ) + const named: Interface["named"] = Effect.fn("ToolRegistry.named")(function* () { + const s = yield* InstanceState.get(state) + return { task: s.task, read: s.read } + }) - export const defaultLayer = Layer.suspend(() => - layer.pipe( - Layer.provide(Config.defaultLayer), - Layer.provide(Plugin.defaultLayer), - Layer.provide(Question.defaultLayer), - Layer.provide(Todo.defaultLayer), - Layer.provide(Skill.defaultLayer), - Layer.provide(Agent.defaultLayer), - Layer.provide(Session.defaultLayer), - Layer.provide(Provider.defaultLayer), - Layer.provide(LSP.defaultLayer), - Layer.provide(FileTime.defaultLayer), - Layer.provide(Instruction.defaultLayer), - Layer.provide(AppFileSystem.defaultLayer), - Layer.provide(Bus.layer), - Layer.provide(FetchHttpClient.layer), - Layer.provide(Format.defaultLayer), - Layer.provide(CrossSpawnSpawner.defaultLayer), - Layer.provide(Ripgrep.defaultLayer), - Layer.provide(Truncate.defaultLayer), - ), - ) -} + return Service.of({ ids, all, named, tools }) + }), +) + +export const defaultLayer = Layer.suspend(() => + layer.pipe( + Layer.provide(Config.defaultLayer), + Layer.provide(Plugin.defaultLayer), + Layer.provide(Question.defaultLayer), + Layer.provide(Todo.defaultLayer), + Layer.provide(Skill.defaultLayer), + Layer.provide(Agent.defaultLayer), + Layer.provide(Session.defaultLayer), + Layer.provide(Provider.defaultLayer), + Layer.provide(LSP.defaultLayer), + Layer.provide(FileTime.defaultLayer), + Layer.provide(Instruction.defaultLayer), + Layer.provide(AppFileSystem.defaultLayer), + Layer.provide(Bus.layer), + Layer.provide(FetchHttpClient.layer), + Layer.provide(Format.defaultLayer), + Layer.provide(CrossSpawnSpawner.defaultLayer), + Layer.provide(Ripgrep.defaultLayer), + Layer.provide(Truncate.defaultLayer), + ), +) diff --git a/packages/opencode/src/tool/skill.ts b/packages/opencode/src/tool/skill.ts index eaec667e58b6..58a66ee7443a 100644 --- a/packages/opencode/src/tool/skill.ts +++ b/packages/opencode/src/tool/skill.ts @@ -6,7 +6,7 @@ import * as Stream from "effect/Stream" import { EffectLogger } from "@/effect" import { Ripgrep } from "../file/ripgrep" import { Skill } from "../skill" -import { Tool } from "./tool" +import * as Tool from "./tool" const Parameters = z.object({ name: z.string().describe("The name of the skill from available_skills"), diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index 8f7104e80d3c..3da0664f3d5a 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -1,4 +1,4 @@ -import { Tool } from "./tool" +import * as Tool from "./tool" import DESCRIPTION from "./task.txt" import z from "zod" import { Session } from "../session" diff --git a/packages/opencode/src/tool/todo.ts b/packages/opencode/src/tool/todo.ts index 253bcfa32ab8..5090f17a7c27 100644 --- a/packages/opencode/src/tool/todo.ts +++ b/packages/opencode/src/tool/todo.ts @@ -1,6 +1,6 @@ import z from "zod" import { Effect } from "effect" -import { Tool } from "./tool" +import * as Tool from "./tool" import DESCRIPTION_WRITE from "./todowrite.txt" import { Todo } from "../session/todo" diff --git a/packages/opencode/src/tool/tool.ts b/packages/opencode/src/tool/tool.ts index ca25862349a1..db390734841d 100644 --- a/packages/opencode/src/tool/tool.ts +++ b/packages/opencode/src/tool/tool.ts @@ -3,146 +3,144 @@ import { Effect } from "effect" import type { MessageV2 } from "../session/message-v2" import type { Permission } from "../permission" import type { SessionID, MessageID } from "../session/schema" -import { Truncate } from "./truncate" +import * as Truncate from "./truncate" import { Agent } from "@/agent/agent" -export namespace Tool { - interface Metadata { - [key: string]: any - } +interface Metadata { + [key: string]: any +} - // TODO: remove this hack - export type DynamicDescription = (agent: Agent.Info) => Effect.Effect +// TODO: remove this hack +export type DynamicDescription = (agent: Agent.Info) => Effect.Effect - export type Context = { - sessionID: SessionID - messageID: MessageID - agent: string - abort: AbortSignal - callID?: string - extra?: { [key: string]: any } - messages: MessageV2.WithParts[] - metadata(input: { title?: string; metadata?: M }): Effect.Effect - ask(input: Omit): Effect.Effect - } +export type Context = { + sessionID: SessionID + messageID: MessageID + agent: string + abort: AbortSignal + callID?: string + extra?: { [key: string]: any } + messages: MessageV2.WithParts[] + metadata(input: { title?: string; metadata?: M }): Effect.Effect + ask(input: Omit): Effect.Effect +} - export interface ExecuteResult { - title: string - metadata: M - output: string - attachments?: Omit[] - } +export interface ExecuteResult { + title: string + metadata: M + output: string + attachments?: Omit[] +} - export interface Def { - id: string - description: string - parameters: Parameters - execute(args: z.infer, ctx: Context): Effect.Effect> - formatValidationError?(error: z.ZodError): string - } - export type DefWithoutID = Omit< - Def, - "id" - > +export interface Def { + id: string + description: string + parameters: Parameters + execute(args: z.infer, ctx: Context): Effect.Effect> + formatValidationError?(error: z.ZodError): string +} +export type DefWithoutID = Omit< + Def, + "id" +> - export interface Info { - id: string - init: () => Effect.Effect> - } +export interface Info { + id: string + init: () => Effect.Effect> +} - type Init = - | DefWithoutID - | (() => Effect.Effect>) +type Init = + | DefWithoutID + | (() => Effect.Effect>) - export type InferParameters = - T extends Info +export type InferParameters = + T extends Info + ? z.infer

+ : T extends Effect.Effect, any, any> ? z.infer

- : T extends Effect.Effect, any, any> - ? z.infer

- : never - export type InferMetadata = - T extends Info ? M : T extends Effect.Effect, any, any> ? M : never + : never +export type InferMetadata = + T extends Info ? M : T extends Effect.Effect, any, any> ? M : never - export type InferDef = - T extends Info +export type InferDef = + T extends Info + ? Def + : T extends Effect.Effect, any, any> ? Def - : T extends Effect.Effect, any, any> - ? Def - : never + : never - function wrap( - id: string, - init: Init, - truncate: Truncate.Interface, - agents: Agent.Interface, - ) { - return () => - Effect.gen(function* () { - const toolInfo = typeof init === "function" ? { ...(yield* init()) } : { ...init } - const execute = toolInfo.execute - toolInfo.execute = (args, ctx) => { - const attrs = { - "tool.name": id, - "session.id": ctx.sessionID, - "message.id": ctx.messageID, - ...(ctx.callID ? { "tool.call_id": ctx.callID } : {}), - } - return Effect.gen(function* () { - yield* Effect.try({ - try: () => toolInfo.parameters.parse(args), - catch: (error) => { - if (error instanceof z.ZodError && toolInfo.formatValidationError) { - return new Error(toolInfo.formatValidationError(error), { cause: error }) - } - return new Error( - `The ${id} tool was called with invalid arguments: ${error}.\nPlease rewrite the input so it satisfies the expected schema.`, - { cause: error }, - ) - }, - }) - const result = yield* execute(args, ctx) - if (result.metadata.truncated !== undefined) { - return result - } - const agent = yield* agents.get(ctx.agent) - const truncated = yield* truncate.output(result.output, {}, agent) - return { - ...result, - output: truncated.content, - metadata: { - ...result.metadata, - truncated: truncated.truncated, - ...(truncated.truncated && { outputPath: truncated.outputPath }), - }, - } - }).pipe(Effect.orDie, Effect.withSpan("Tool.execute", { attributes: attrs })) +function wrap( + id: string, + init: Init, + truncate: Truncate.Interface, + agents: Agent.Interface, +) { + return () => + Effect.gen(function* () { + const toolInfo = typeof init === "function" ? { ...(yield* init()) } : { ...init } + const execute = toolInfo.execute + toolInfo.execute = (args, ctx) => { + const attrs = { + "tool.name": id, + "session.id": ctx.sessionID, + "message.id": ctx.messageID, + ...(ctx.callID ? { "tool.call_id": ctx.callID } : {}), } - return toolInfo - }) - } - - export function define( - id: ID, - init: Effect.Effect, never, R>, - ): Effect.Effect, never, R | Truncate.Service | Agent.Service> & { id: ID } { - return Object.assign( - Effect.gen(function* () { - const resolved = yield* init - const truncate = yield* Truncate.Service - const agents = yield* Agent.Service - return { id, init: wrap(id, resolved, truncate, agents) } - }), - { id }, - ) - } - - export function init

(info: Info): Effect.Effect> { - return Effect.gen(function* () { - const init = yield* info.init() - return { - ...init, - id: info.id, + return Effect.gen(function* () { + yield* Effect.try({ + try: () => toolInfo.parameters.parse(args), + catch: (error) => { + if (error instanceof z.ZodError && toolInfo.formatValidationError) { + return new Error(toolInfo.formatValidationError(error), { cause: error }) + } + return new Error( + `The ${id} tool was called with invalid arguments: ${error}.\nPlease rewrite the input so it satisfies the expected schema.`, + { cause: error }, + ) + }, + }) + const result = yield* execute(args, ctx) + if (result.metadata.truncated !== undefined) { + return result + } + const agent = yield* agents.get(ctx.agent) + const truncated = yield* truncate.output(result.output, {}, agent) + return { + ...result, + output: truncated.content, + metadata: { + ...result.metadata, + truncated: truncated.truncated, + ...(truncated.truncated && { outputPath: truncated.outputPath }), + }, + } + }).pipe(Effect.orDie, Effect.withSpan("Tool.execute", { attributes: attrs })) } + return toolInfo }) - } +} + +export function define( + id: ID, + init: Effect.Effect, never, R>, +): Effect.Effect, never, R | Truncate.Service | Agent.Service> & { id: ID } { + return Object.assign( + Effect.gen(function* () { + const resolved = yield* init + const truncate = yield* Truncate.Service + const agents = yield* Agent.Service + return { id, init: wrap(id, resolved, truncate, agents) } + }), + { id }, + ) +} + +export function init

(info: Info): Effect.Effect> { + return Effect.gen(function* () { + const init = yield* info.init() + return { + ...init, + id: info.id, + } + }) } diff --git a/packages/opencode/src/tool/truncate.ts b/packages/opencode/src/tool/truncate.ts index d2aa944a85d7..d990e7adf7f9 100644 --- a/packages/opencode/src/tool/truncate.ts +++ b/packages/opencode/src/tool/truncate.ts @@ -9,136 +9,134 @@ import { Log } from "../util" import { ToolID } from "./schema" import { TRUNCATION_DIR } from "./truncation-dir" -export namespace Truncate { - const log = Log.create({ service: "truncation" }) - const RETENTION = Duration.days(7) - - export const MAX_LINES = 2000 - export const MAX_BYTES = 50 * 1024 - export const DIR = TRUNCATION_DIR - export const GLOB = path.join(TRUNCATION_DIR, "*") - - export type Result = { content: string; truncated: false } | { content: string; truncated: true; outputPath: string } - - export interface Options { - maxLines?: number - maxBytes?: number - direction?: "head" | "tail" - } - - function hasTaskTool(agent?: Agent.Info) { - if (!agent?.permission) return false - return evaluate("task", "*", agent.permission).action !== "deny" - } - - export interface Interface { - readonly cleanup: () => Effect.Effect - readonly write: (text: string) => Effect.Effect - /** - * Returns output unchanged when it fits within the limits, otherwise writes the full text - * to the truncation directory and returns a preview plus a hint to inspect the saved file. - */ - readonly output: (text: string, options?: Options, agent?: Agent.Info) => Effect.Effect - } - - export class Service extends Context.Service()("@opencode/Truncate") {} - - export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const fs = yield* AppFileSystem.Service - - const cleanup = Effect.fn("Truncate.cleanup")(function* () { - const cutoff = Identifier.timestamp( - Identifier.create("tool", "ascending", Date.now() - Duration.toMillis(RETENTION)), - ) - const entries = yield* fs.readDirectory(TRUNCATION_DIR).pipe( - Effect.map((all) => all.filter((name) => name.startsWith("tool_"))), - Effect.catch(() => Effect.succeed([])), - ) - for (const entry of entries) { - if (Identifier.timestamp(entry) >= cutoff) continue - yield* fs.remove(path.join(TRUNCATION_DIR, entry)).pipe(Effect.catch(() => Effect.void)) - } - }) - - const write = Effect.fn("Truncate.write")(function* (text: string) { - const file = path.join(TRUNCATION_DIR, ToolID.ascending()) - yield* fs.ensureDir(TRUNCATION_DIR).pipe(Effect.orDie) - yield* fs.writeFileString(file, text).pipe(Effect.orDie) - return file - }) - - const output = Effect.fn("Truncate.output")(function* (text: string, options: Options = {}, agent?: Agent.Info) { - const maxLines = options.maxLines ?? MAX_LINES - const maxBytes = options.maxBytes ?? MAX_BYTES - const direction = options.direction ?? "head" - const lines = text.split("\n") - const totalBytes = Buffer.byteLength(text, "utf-8") - - if (lines.length <= maxLines && totalBytes <= maxBytes) { - return { content: text, truncated: false } as const - } +const log = Log.create({ service: "truncation" }) +const RETENTION = Duration.days(7) - const out: string[] = [] - let i = 0 - let bytes = 0 - let hitBytes = false - - if (direction === "head") { - for (i = 0; i < lines.length && i < maxLines; i++) { - const size = Buffer.byteLength(lines[i], "utf-8") + (i > 0 ? 1 : 0) - if (bytes + size > maxBytes) { - hitBytes = true - break - } - out.push(lines[i]) - bytes += size - } - } else { - for (i = lines.length - 1; i >= 0 && out.length < maxLines; i--) { - const size = Buffer.byteLength(lines[i], "utf-8") + (out.length > 0 ? 1 : 0) - if (bytes + size > maxBytes) { - hitBytes = true - break - } - out.unshift(lines[i]) - bytes += size - } - } +export const MAX_LINES = 2000 +export const MAX_BYTES = 50 * 1024 +export const DIR = TRUNCATION_DIR +export const GLOB = path.join(TRUNCATION_DIR, "*") - const removed = hitBytes ? totalBytes - bytes : lines.length - out.length - const unit = hitBytes ? "bytes" : "lines" - const preview = out.join("\n") - const file = yield* write(text) - - const hint = hasTaskTool(agent) - ? `The tool call succeeded but the output was truncated. Full output saved to: ${file}\nUse the Task tool to have explore agent process this file with Grep and Read (with offset/limit). Do NOT read the full file yourself - delegate to save context.` - : `The tool call succeeded but the output was truncated. Full output saved to: ${file}\nUse Grep to search the full content or Read with offset/limit to view specific sections.` - - return { - content: - direction === "head" - ? `${preview}\n\n...${removed} ${unit} truncated...\n\n${hint}` - : `...${removed} ${unit} truncated...\n\n${hint}\n\n${preview}`, - truncated: true, - outputPath: file, - } as const - }) - - yield* cleanup().pipe( - Effect.catchCause((cause) => { - log.error("truncation cleanup failed", { cause: Cause.pretty(cause) }) - return Effect.void - }), - Effect.repeat(Schedule.spaced(Duration.hours(1))), - Effect.delay(Duration.minutes(1)), - Effect.forkScoped, - ) +export type Result = { content: string; truncated: false } | { content: string; truncated: true; outputPath: string } + +export interface Options { + maxLines?: number + maxBytes?: number + direction?: "head" | "tail" +} - return Service.of({ cleanup, write, output }) - }), - ) +function hasTaskTool(agent?: Agent.Info) { + if (!agent?.permission) return false + return evaluate("task", "*", agent.permission).action !== "deny" +} - export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(NodePath.layer)) +export interface Interface { + readonly cleanup: () => Effect.Effect + readonly write: (text: string) => Effect.Effect + /** + * Returns output unchanged when it fits within the limits, otherwise writes the full text + * to the truncation directory and returns a preview plus a hint to inspect the saved file. + */ + readonly output: (text: string, options?: Options, agent?: Agent.Info) => Effect.Effect } + +export class Service extends Context.Service()("@opencode/Truncate") {} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + + const cleanup = Effect.fn("Truncate.cleanup")(function* () { + const cutoff = Identifier.timestamp( + Identifier.create("tool", "ascending", Date.now() - Duration.toMillis(RETENTION)), + ) + const entries = yield* fs.readDirectory(TRUNCATION_DIR).pipe( + Effect.map((all) => all.filter((name) => name.startsWith("tool_"))), + Effect.catch(() => Effect.succeed([])), + ) + for (const entry of entries) { + if (Identifier.timestamp(entry) >= cutoff) continue + yield* fs.remove(path.join(TRUNCATION_DIR, entry)).pipe(Effect.catch(() => Effect.void)) + } + }) + + const write = Effect.fn("Truncate.write")(function* (text: string) { + const file = path.join(TRUNCATION_DIR, ToolID.ascending()) + yield* fs.ensureDir(TRUNCATION_DIR).pipe(Effect.orDie) + yield* fs.writeFileString(file, text).pipe(Effect.orDie) + return file + }) + + const output = Effect.fn("Truncate.output")(function* (text: string, options: Options = {}, agent?: Agent.Info) { + const maxLines = options.maxLines ?? MAX_LINES + const maxBytes = options.maxBytes ?? MAX_BYTES + const direction = options.direction ?? "head" + const lines = text.split("\n") + const totalBytes = Buffer.byteLength(text, "utf-8") + + if (lines.length <= maxLines && totalBytes <= maxBytes) { + return { content: text, truncated: false } as const + } + + const out: string[] = [] + let i = 0 + let bytes = 0 + let hitBytes = false + + if (direction === "head") { + for (i = 0; i < lines.length && i < maxLines; i++) { + const size = Buffer.byteLength(lines[i], "utf-8") + (i > 0 ? 1 : 0) + if (bytes + size > maxBytes) { + hitBytes = true + break + } + out.push(lines[i]) + bytes += size + } + } else { + for (i = lines.length - 1; i >= 0 && out.length < maxLines; i--) { + const size = Buffer.byteLength(lines[i], "utf-8") + (out.length > 0 ? 1 : 0) + if (bytes + size > maxBytes) { + hitBytes = true + break + } + out.unshift(lines[i]) + bytes += size + } + } + + const removed = hitBytes ? totalBytes - bytes : lines.length - out.length + const unit = hitBytes ? "bytes" : "lines" + const preview = out.join("\n") + const file = yield* write(text) + + const hint = hasTaskTool(agent) + ? `The tool call succeeded but the output was truncated. Full output saved to: ${file}\nUse the Task tool to have explore agent process this file with Grep and Read (with offset/limit). Do NOT read the full file yourself - delegate to save context.` + : `The tool call succeeded but the output was truncated. Full output saved to: ${file}\nUse Grep to search the full content or Read with offset/limit to view specific sections.` + + return { + content: + direction === "head" + ? `${preview}\n\n...${removed} ${unit} truncated...\n\n${hint}` + : `...${removed} ${unit} truncated...\n\n${hint}\n\n${preview}`, + truncated: true, + outputPath: file, + } as const + }) + + yield* cleanup().pipe( + Effect.catchCause((cause) => { + log.error("truncation cleanup failed", { cause: Cause.pretty(cause) }) + return Effect.void + }), + Effect.repeat(Schedule.spaced(Duration.hours(1))), + Effect.delay(Duration.minutes(1)), + Effect.forkScoped, + ) + + return Service.of({ cleanup, write, output }) + }), +) + +export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(NodePath.layer)) diff --git a/packages/opencode/src/tool/webfetch.ts b/packages/opencode/src/tool/webfetch.ts index 14d5465846d1..6498b871f83a 100644 --- a/packages/opencode/src/tool/webfetch.ts +++ b/packages/opencode/src/tool/webfetch.ts @@ -1,7 +1,7 @@ import z from "zod" import { Effect } from "effect" import { HttpClient, HttpClientRequest } from "effect/unstable/http" -import { Tool } from "./tool" +import * as Tool from "./tool" import TurndownService from "turndown" import DESCRIPTION from "./webfetch.txt" diff --git a/packages/opencode/src/tool/websearch.ts b/packages/opencode/src/tool/websearch.ts index 968e1e34b6e4..34cefd031f49 100644 --- a/packages/opencode/src/tool/websearch.ts +++ b/packages/opencode/src/tool/websearch.ts @@ -1,7 +1,7 @@ import z from "zod" import { Effect } from "effect" import { HttpClient } from "effect/unstable/http" -import { Tool } from "./tool" +import * as Tool from "./tool" import * as McpExa from "./mcp-exa" import DESCRIPTION from "./websearch.txt" diff --git a/packages/opencode/src/tool/write.ts b/packages/opencode/src/tool/write.ts index 337c2708c9e3..c5871eb0ef26 100644 --- a/packages/opencode/src/tool/write.ts +++ b/packages/opencode/src/tool/write.ts @@ -1,7 +1,7 @@ import z from "zod" import * as path from "path" import { Effect } from "effect" -import { Tool } from "./tool" +import * as Tool from "./tool" import { LSP } from "../lsp" import { createTwoFilesPatch } from "diff" import DESCRIPTION from "./write.txt" diff --git a/packages/opencode/test/agent/agent.test.ts b/packages/opencode/test/agent/agent.test.ts index 409a0ed606fa..7e9a6fe90bd1 100644 --- a/packages/opencode/test/agent/agent.test.ts +++ b/packages/opencode/test/agent/agent.test.ts @@ -84,7 +84,7 @@ test("explore agent denies edit and write", async () => { }) test("explore agent asks for external directories and allows Truncate.GLOB", async () => { - const { Truncate } = await import("../../src/tool/truncate") + const { Truncate } = await import("../../src/tool") await using tmp = await tmpdir() await Instance.provide({ directory: tmp.path, @@ -496,7 +496,7 @@ test("legacy tools config maps write/edit/patch/multiedit to edit permission", a }) test("Truncate.GLOB is allowed even when user denies external_directory globally", async () => { - const { Truncate } = await import("../../src/tool/truncate") + const { Truncate } = await import("../../src/tool") await using tmp = await tmpdir({ config: { permission: { @@ -516,7 +516,7 @@ test("Truncate.GLOB is allowed even when user denies external_directory globally }) test("Truncate.GLOB is allowed even when user denies external_directory per-agent", async () => { - const { Truncate } = await import("../../src/tool/truncate") + const { Truncate } = await import("../../src/tool") await using tmp = await tmpdir({ config: { agent: { @@ -540,7 +540,7 @@ test("Truncate.GLOB is allowed even when user denies external_directory per-agen }) test("explicit Truncate.GLOB deny is respected", async () => { - const { Truncate } = await import("../../src/tool/truncate") + const { Truncate } = await import("../../src/tool") await using tmp = await tmpdir({ config: { permission: { diff --git a/packages/opencode/test/session/prompt-effect.test.ts b/packages/opencode/test/session/prompt-effect.test.ts index 6819da481731..121d662e5fe0 100644 --- a/packages/opencode/test/session/prompt-effect.test.ts +++ b/packages/opencode/test/session/prompt-effect.test.ts @@ -34,8 +34,8 @@ import { Skill } from "../../src/skill" import { SystemPrompt } from "../../src/session/system" import { Shell } from "../../src/shell/shell" import { Snapshot } from "../../src/snapshot" -import { ToolRegistry } from "../../src/tool/registry" -import { Truncate } from "../../src/tool/truncate" +import { ToolRegistry } from "../../src/tool" +import { Truncate } from "../../src/tool" import { Log } from "../../src/util" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" import { Ripgrep } from "../../src/file/ripgrep" diff --git a/packages/opencode/test/session/snapshot-tool-race.test.ts b/packages/opencode/test/session/snapshot-tool-race.test.ts index 38aed437658a..1f66ccb9950c 100644 --- a/packages/opencode/test/session/snapshot-tool-race.test.ts +++ b/packages/opencode/test/session/snapshot-tool-race.test.ts @@ -50,8 +50,8 @@ import { SessionProcessor } from "../../src/session/processor" import { SessionRunState } from "../../src/session/run-state" import { SessionStatus } from "../../src/session/status" import { Snapshot } from "../../src/snapshot" -import { ToolRegistry } from "../../src/tool/registry" -import { Truncate } from "../../src/tool/truncate" +import { ToolRegistry } from "../../src/tool" +import { Truncate } from "../../src/tool" import { AppFileSystem } from "@opencode-ai/shared/filesystem" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" import { Ripgrep } from "../../src/file/ripgrep" diff --git a/packages/opencode/test/tool/apply_patch.test.ts b/packages/opencode/test/tool/apply_patch.test.ts index c0448c78cb28..ebfa9a531eec 100644 --- a/packages/opencode/test/tool/apply_patch.test.ts +++ b/packages/opencode/test/tool/apply_patch.test.ts @@ -9,7 +9,7 @@ import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { Format } from "../../src/format" import { Agent } from "../../src/agent/agent" import { Bus } from "../../src/bus" -import { Truncate } from "../../src/tool/truncate" +import { Truncate } from "../../src/tool" import { tmpdir } from "../fixture/fixture" import { SessionID, MessageID } from "../../src/session/schema" diff --git a/packages/opencode/test/tool/bash.test.ts b/packages/opencode/test/tool/bash.test.ts index 6a3eac15e033..d66cfc3e370a 100644 --- a/packages/opencode/test/tool/bash.test.ts +++ b/packages/opencode/test/tool/bash.test.ts @@ -9,7 +9,7 @@ import { Filesystem } from "../../src/util" import { tmpdir } from "../fixture/fixture" import type { Permission } from "../../src/permission" import { Agent } from "../../src/agent/agent" -import { Truncate } from "../../src/tool/truncate" +import { Truncate } from "../../src/tool" import { SessionID, MessageID } from "../../src/session/schema" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" import { AppFileSystem } from "@opencode-ai/shared/filesystem" diff --git a/packages/opencode/test/tool/edit.test.ts b/packages/opencode/test/tool/edit.test.ts index 37a19a5fda15..2e3dfa8a6949 100644 --- a/packages/opencode/test/tool/edit.test.ts +++ b/packages/opencode/test/tool/edit.test.ts @@ -12,7 +12,7 @@ import { Format } from "../../src/format" import { Agent } from "../../src/agent/agent" import { Bus } from "../../src/bus" import { BusEvent } from "../../src/bus/bus-event" -import { Truncate } from "../../src/tool/truncate" +import { Truncate } from "../../src/tool" import { SessionID, MessageID } from "../../src/session/schema" const ctx = { diff --git a/packages/opencode/test/tool/external-directory.test.ts b/packages/opencode/test/tool/external-directory.test.ts index ee8cb539632d..8cbfe7827048 100644 --- a/packages/opencode/test/tool/external-directory.test.ts +++ b/packages/opencode/test/tool/external-directory.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test" import path from "path" import { Effect } from "effect" -import type { Tool } from "../../src/tool/tool" +import type { Tool } from "../../src/tool" import { Instance } from "../../src/project/instance" import { assertExternalDirectory } from "../../src/tool/external-directory" import { Filesystem } from "../../src/util" diff --git a/packages/opencode/test/tool/glob.test.ts b/packages/opencode/test/tool/glob.test.ts index 20e761fc10fd..87d35715dd6f 100644 --- a/packages/opencode/test/tool/glob.test.ts +++ b/packages/opencode/test/tool/glob.test.ts @@ -6,7 +6,7 @@ import { SessionID, MessageID } from "../../src/session/schema" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" import { Ripgrep } from "../../src/file/ripgrep" import { AppFileSystem } from "@opencode-ai/shared/filesystem" -import { Truncate } from "../../src/tool/truncate" +import { Truncate } from "../../src/tool" import { Agent } from "../../src/agent/agent" import { provideTmpdirInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" diff --git a/packages/opencode/test/tool/grep.test.ts b/packages/opencode/test/tool/grep.test.ts index 35467aeab401..388828f6eb8e 100644 --- a/packages/opencode/test/tool/grep.test.ts +++ b/packages/opencode/test/tool/grep.test.ts @@ -5,7 +5,7 @@ import { GrepTool } from "../../src/tool/grep" import { provideInstance, provideTmpdirInstance } from "../fixture/fixture" import { SessionID, MessageID } from "../../src/session/schema" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" -import { Truncate } from "../../src/tool/truncate" +import { Truncate } from "../../src/tool" import { Agent } from "../../src/agent/agent" import { Ripgrep } from "../../src/file/ripgrep" import { AppFileSystem } from "@opencode-ai/shared/filesystem" diff --git a/packages/opencode/test/tool/question.test.ts b/packages/opencode/test/tool/question.test.ts index 629e5d2d28e4..17718b2b3a13 100644 --- a/packages/opencode/test/tool/question.test.ts +++ b/packages/opencode/test/tool/question.test.ts @@ -5,7 +5,7 @@ import { Question } from "../../src/question" import { SessionID, MessageID } from "../../src/session/schema" import { Agent } from "../../src/agent/agent" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" -import { Truncate } from "../../src/tool/truncate" +import { Truncate } from "../../src/tool" import { provideTmpdirInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" diff --git a/packages/opencode/test/tool/read.test.ts b/packages/opencode/test/tool/read.test.ts index fa65068f8661..8e1724b47401 100644 --- a/packages/opencode/test/tool/read.test.ts +++ b/packages/opencode/test/tool/read.test.ts @@ -11,8 +11,8 @@ import { Instance } from "../../src/project/instance" import { SessionID, MessageID } from "../../src/session/schema" import { Instruction } from "../../src/session/instruction" import { ReadTool } from "../../src/tool/read" -import { Truncate } from "../../src/tool/truncate" -import { Tool } from "../../src/tool/tool" +import { Truncate } from "../../src/tool" +import { Tool } from "../../src/tool" import { Filesystem } from "../../src/util" import { provideInstance, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts index dea84bdcd4d0..dbb89e09a932 100644 --- a/packages/opencode/test/tool/registry.test.ts +++ b/packages/opencode/test/tool/registry.test.ts @@ -4,7 +4,7 @@ import fs from "fs/promises" import { Effect, Layer } from "effect" import { Instance } from "../../src/project/instance" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" -import { ToolRegistry } from "../../src/tool/registry" +import { ToolRegistry } from "../../src/tool" import { provideTmpdirInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" diff --git a/packages/opencode/test/tool/skill.test.ts b/packages/opencode/test/tool/skill.test.ts index 9b92a8cd3060..55e126ab47b8 100644 --- a/packages/opencode/test/tool/skill.test.ts +++ b/packages/opencode/test/tool/skill.test.ts @@ -4,10 +4,10 @@ import { afterEach, describe, expect } 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 type { Tool } from "../../src/tool" import { Instance } from "../../src/project/instance" import { SkillTool } from "../../src/tool/skill" -import { ToolRegistry } from "../../src/tool/registry" +import { ToolRegistry } from "../../src/tool" import { provideTmpdirInstance } from "../fixture/fixture" import { SessionID, MessageID } from "../../src/session/schema" import { testEffect } from "../lib/effect" diff --git a/packages/opencode/test/tool/task.test.ts b/packages/opencode/test/tool/task.test.ts index bc90dc0f22cc..b94dd5208655 100644 --- a/packages/opencode/test/tool/task.test.ts +++ b/packages/opencode/test/tool/task.test.ts @@ -10,8 +10,8 @@ import type { SessionPrompt } from "../../src/session/prompt" import { MessageID, PartID } from "../../src/session/schema" import { ModelID, ProviderID } from "../../src/provider/schema" import { TaskTool, type TaskPromptOps } from "../../src/tool/task" -import { Truncate } from "../../src/tool/truncate" -import { ToolRegistry } from "../../src/tool/registry" +import { Truncate } from "../../src/tool" +import { ToolRegistry } from "../../src/tool" import { provideTmpdirInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" diff --git a/packages/opencode/test/tool/tool-define.test.ts b/packages/opencode/test/tool/tool-define.test.ts index b8003e475df0..00d1e039a7dd 100644 --- a/packages/opencode/test/tool/tool-define.test.ts +++ b/packages/opencode/test/tool/tool-define.test.ts @@ -2,8 +2,8 @@ import { describe, test, expect } from "bun:test" import { Effect, Layer, ManagedRuntime } from "effect" import z from "zod" import { Agent } from "../../src/agent/agent" -import { Tool } from "../../src/tool/tool" -import { Truncate } from "../../src/tool/truncate" +import { Tool } from "../../src/tool" +import { Truncate } from "../../src/tool" const runtime = ManagedRuntime.make(Layer.mergeAll(Truncate.defaultLayer, Agent.defaultLayer)) diff --git a/packages/opencode/test/tool/truncation.test.ts b/packages/opencode/test/tool/truncation.test.ts index d0873046d6ca..d3cec4cd9e30 100644 --- a/packages/opencode/test/tool/truncation.test.ts +++ b/packages/opencode/test/tool/truncation.test.ts @@ -1,7 +1,7 @@ import { describe, test, expect } from "bun:test" import { NodeFileSystem } from "@effect/platform-node" import { Effect, FileSystem, Layer } from "effect" -import { Truncate } from "../../src/tool/truncate" +import { Truncate } from "../../src/tool" import { Identifier } from "../../src/id/id" import { Process } from "../../src/util" import { Filesystem } from "../../src/util" diff --git a/packages/opencode/test/tool/webfetch.test.ts b/packages/opencode/test/tool/webfetch.test.ts index 7d2ff1dcab6c..699e388fb902 100644 --- a/packages/opencode/test/tool/webfetch.test.ts +++ b/packages/opencode/test/tool/webfetch.test.ts @@ -3,7 +3,7 @@ import path from "path" import { Effect, Layer } from "effect" import { FetchHttpClient } from "effect/unstable/http" import { Agent } from "../../src/agent/agent" -import { Truncate } from "../../src/tool/truncate" +import { Truncate } from "../../src/tool" import { Instance } from "../../src/project/instance" import { WebFetchTool } from "../../src/tool/webfetch" import { SessionID, MessageID } from "../../src/session/schema" diff --git a/packages/opencode/test/tool/write.test.ts b/packages/opencode/test/tool/write.test.ts index e83ec2efdbbc..46bbe2e40109 100644 --- a/packages/opencode/test/tool/write.test.ts +++ b/packages/opencode/test/tool/write.test.ts @@ -9,8 +9,8 @@ import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { FileTime } from "../../src/file/time" import { Bus } from "../../src/bus" import { Format } from "../../src/format" -import { Truncate } from "../../src/tool/truncate" -import { Tool } from "../../src/tool/tool" +import { Truncate } from "../../src/tool" +import { Tool } from "../../src/tool" import { Agent } from "../../src/agent/agent" import { SessionID, MessageID } from "../../src/session/schema" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"