feat: unwrap tool namespaces to flat exports + barrel (#22762)

This commit is contained in:
Kit Langton
2026-04-16 03:56:54 +00:00
committed by GitHub
parent f6cc228684
commit 5011465c81
48 changed files with 585 additions and 588 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ import { Provider } from "../provider"
import { ModelID, ProviderID } from "../provider/schema" import { ModelID, ProviderID } from "../provider/schema"
import { generateObject, streamObject, type ModelMessage } from "ai" import { generateObject, streamObject, type ModelMessage } from "ai"
import { Instance } from "../project/instance" import { Instance } from "../project/instance"
import { Truncate } from "../tool/truncate" import { Truncate } from "../tool"
import { Auth } from "../auth" import { Auth } from "../auth"
import { ProviderTransform } from "../provider/transform" import { ProviderTransform } from "../provider/transform"
+1 -1
View File
@@ -6,7 +6,7 @@ import { Provider } from "../../../provider"
import { Session } from "../../../session" import { Session } from "../../../session"
import type { MessageV2 } from "../../../session/message-v2" import type { MessageV2 } from "../../../session/message-v2"
import { MessageID, PartID } from "../../../session/schema" import { MessageID, PartID } from "../../../session/schema"
import { ToolRegistry } from "../../../tool/registry" import { ToolRegistry } from "../../../tool"
import { Instance } from "../../../project/instance" import { Instance } from "../../../project/instance"
import { Permission } from "../../../permission" import { Permission } from "../../../permission"
import { iife } from "../../../util/iife" import { iife } from "../../../util/iife"
+1 -1
View File
@@ -12,7 +12,7 @@ import { Server } from "../../server/server"
import { Provider } from "../../provider" import { Provider } from "../../provider"
import { Agent } from "../../agent/agent" import { Agent } from "../../agent/agent"
import { Permission } from "../../permission" import { Permission } from "../../permission"
import { Tool } from "../../tool/tool" import { Tool } from "../../tool"
import { GlobTool } from "../../tool/glob" import { GlobTool } from "../../tool/glob"
import { GrepTool } from "../../tool/grep" import { GrepTool } from "../../tool/grep"
import { ReadTool } from "../../tool/read" import { ReadTool } from "../../tool/read"
@@ -34,7 +34,7 @@ import type {
} from "@opencode-ai/sdk/v2" } from "@opencode-ai/sdk/v2"
import { useLocal } from "@tui/context/local" import { useLocal } from "@tui/context/local"
import { Locale } from "@/util" import { Locale } from "@/util"
import type { Tool } from "@/tool/tool" import type { Tool } from "@/tool"
import type { ReadTool } from "@/tool/read" import type { ReadTool } from "@/tool/read"
import type { WriteTool } from "@/tool/write" import type { WriteTool } from "@/tool/write"
import { BashTool } from "@/tool/bash" import { BashTool } from "@/tool/bash"
+2 -2
View File
@@ -37,8 +37,8 @@ import { LSP } from "@/lsp"
import { MCP } from "@/mcp" import { MCP } from "@/mcp"
import { McpAuth } from "@/mcp/auth" import { McpAuth } from "@/mcp/auth"
import { Command } from "@/command" import { Command } from "@/command"
import { Truncate } from "@/tool/truncate" import { Truncate } from "@/tool"
import { ToolRegistry } from "@/tool/registry" import { ToolRegistry } from "@/tool"
import { Format } from "@/format" import { Format } from "@/format"
import { Project } from "@/project" import { Project } from "@/project"
import { Vcs } from "@/project" import { Vcs } from "@/project"
@@ -2,7 +2,7 @@ import { Hono } from "hono"
import { describeRoute, validator, resolver } from "hono-openapi" import { describeRoute, validator, resolver } from "hono-openapi"
import z from "zod" import z from "zod"
import { ProviderID, ModelID } from "../../provider/schema" import { ProviderID, ModelID } from "../../provider/schema"
import { ToolRegistry } from "../../tool/registry" import { ToolRegistry } from "../../tool"
import { Worktree } from "../../worktree" import { Worktree } from "../../worktree"
import { Instance } from "../../project/instance" import { Instance } from "../../project/instance"
import { Project } from "../../project" import { Project } from "../../project"
+3 -3
View File
@@ -19,7 +19,7 @@ import { Plugin } from "../plugin"
import PROMPT_PLAN from "../session/prompt/plan.txt" import PROMPT_PLAN from "../session/prompt/plan.txt"
import BUILD_SWITCH from "../session/prompt/build-switch.txt" import BUILD_SWITCH from "../session/prompt/build-switch.txt"
import MAX_STEPS from "../session/prompt/max-steps.txt" import MAX_STEPS from "../session/prompt/max-steps.txt"
import { ToolRegistry } from "../tool/registry" import { ToolRegistry } from "../tool"
import { MCP } from "../mcp" import { MCP } from "../mcp"
import { LSP } from "../lsp" import { LSP } from "../lsp"
import { FileTime } from "../file/time" import { FileTime } from "../file/time"
@@ -34,13 +34,13 @@ import { ConfigMarkdown } from "../config"
import { SessionSummary } from "./summary" import { SessionSummary } from "./summary"
import { NamedError } from "@opencode-ai/shared/util/error" import { NamedError } from "@opencode-ai/shared/util/error"
import { SessionProcessor } from "./processor" import { SessionProcessor } from "./processor"
import { Tool } from "@/tool/tool" import { Tool } from "@/tool"
import { Permission } from "@/permission" import { Permission } from "@/permission"
import { SessionStatus } from "./status" import { SessionStatus } from "./status"
import { LLM } from "./llm" import { LLM } from "./llm"
import { Shell } from "@/shell/shell" import { Shell } from "@/shell/shell"
import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { Truncate } from "@/tool/truncate" import { Truncate } from "@/tool"
import { decodeDataUrl } from "@/util/data-url" import { decodeDataUrl } from "@/util/data-url"
import { Process } from "@/util" import { Process } from "@/util"
import { Cause, Effect, Exit, Layer, Option, Scope, Context } from "effect" import { Cause, Effect, Exit, Layer, Option, Scope, Context } from "effect"
+1 -1
View File
@@ -1,7 +1,7 @@
import z from "zod" import z from "zod"
import * as path from "path" import * as path from "path"
import { Effect } from "effect" import { Effect } from "effect"
import { Tool } from "./tool" import * as Tool from "./tool"
import { Bus } from "../bus" import { Bus } from "../bus"
import { FileWatcher } from "../file/watcher" import { FileWatcher } from "../file/watcher"
import { Instance } from "../project/instance" import { Instance } from "../project/instance"
+2 -2
View File
@@ -1,7 +1,7 @@
import z from "zod" import z from "zod"
import os from "os" import os from "os"
import { createWriteStream } from "node:fs" import { createWriteStream } from "node:fs"
import { Tool } from "./tool" import * as Tool from "./tool"
import path from "path" import path from "path"
import DESCRIPTION from "./bash.txt" import DESCRIPTION from "./bash.txt"
import { Log } from "../util" import { Log } from "../util"
@@ -15,7 +15,7 @@ import { Flag } from "@/flag/flag"
import { Shell } from "@/shell/shell" import { Shell } from "@/shell/shell"
import { BashArity } from "@/permission/arity" import { BashArity } from "@/permission/arity"
import { Truncate } from "./truncate" import * as Truncate from "./truncate"
import { Plugin } from "@/plugin" import { Plugin } from "@/plugin"
import { Effect, Stream } from "effect" import { Effect, Stream } from "effect"
import { ChildProcess } from "effect/unstable/process" import { ChildProcess } from "effect/unstable/process"
+1 -1
View File
@@ -1,7 +1,7 @@
import z from "zod" import z from "zod"
import { Effect } from "effect" import { Effect } from "effect"
import { HttpClient } from "effect/unstable/http" import { HttpClient } from "effect/unstable/http"
import { Tool } from "./tool" import * as Tool from "./tool"
import * as McpExa from "./mcp-exa" import * as McpExa from "./mcp-exa"
import DESCRIPTION from "./codesearch.txt" import DESCRIPTION from "./codesearch.txt"
+1 -1
View File
@@ -6,7 +6,7 @@
import z from "zod" import z from "zod"
import * as path from "path" import * as path from "path"
import { Effect } from "effect" import { Effect } from "effect"
import { Tool } from "./tool" import * as Tool from "./tool"
import { LSP } from "../lsp" import { LSP } from "../lsp"
import { createTwoFilesPatch, diffLines } from "diff" import { createTwoFilesPatch, diffLines } from "diff"
import DESCRIPTION from "./edit.txt" import DESCRIPTION from "./edit.txt"
@@ -2,7 +2,7 @@ import path from "path"
import { Effect } from "effect" import { Effect } from "effect"
import { EffectLogger } from "@/effect" import { EffectLogger } from "@/effect"
import { InstanceState } from "@/effect" import { InstanceState } from "@/effect"
import type { Tool } from "./tool" import type * as Tool from "./tool"
import { Instance } from "../project/instance" import { Instance } from "../project/instance"
import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { AppFileSystem } from "@opencode-ai/shared/filesystem"
+1 -1
View File
@@ -7,7 +7,7 @@ import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { Ripgrep } from "../file/ripgrep" import { Ripgrep } from "../file/ripgrep"
import { assertExternalDirectoryEffect } from "./external-directory" import { assertExternalDirectoryEffect } from "./external-directory"
import DESCRIPTION from "./glob.txt" import DESCRIPTION from "./glob.txt"
import { Tool } from "./tool" import * as Tool from "./tool"
export const GlobTool = Tool.define( export const GlobTool = Tool.define(
"glob", "glob",
+1 -1
View File
@@ -6,7 +6,7 @@ import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { Ripgrep } from "../file/ripgrep" import { Ripgrep } from "../file/ripgrep"
import { assertExternalDirectoryEffect } from "./external-directory" import { assertExternalDirectoryEffect } from "./external-directory"
import DESCRIPTION from "./grep.txt" import DESCRIPTION from "./grep.txt"
import { Tool } from "./tool" import * as Tool from "./tool"
const MAX_LINE_LENGTH = 2000 const MAX_LINE_LENGTH = 2000
+3
View File
@@ -0,0 +1,3 @@
export * as Truncate from "./truncate"
export * as ToolRegistry from "./registry"
export * as Tool from "./tool"
+1 -1
View File
@@ -1,6 +1,6 @@
import z from "zod" import z from "zod"
import { Effect } from "effect" import { Effect } from "effect"
import { Tool } from "./tool" import * as Tool from "./tool"
export const InvalidTool = Tool.define( export const InvalidTool = Tool.define(
"invalid", "invalid",
+1 -1
View File
@@ -1,6 +1,6 @@
import z from "zod" import z from "zod"
import { Effect } from "effect" import { Effect } from "effect"
import { Tool } from "./tool" import * as Tool from "./tool"
import path from "path" import path from "path"
import { LSP } from "../lsp" import { LSP } from "../lsp"
import DESCRIPTION from "./lsp.txt" import DESCRIPTION from "./lsp.txt"
+1 -1
View File
@@ -1,6 +1,6 @@
import z from "zod" import z from "zod"
import { Effect } from "effect" import { Effect } from "effect"
import { Tool } from "./tool" import * as Tool from "./tool"
import { EditTool } from "./edit" import { EditTool } from "./edit"
import DESCRIPTION from "./multiedit.txt" import DESCRIPTION from "./multiedit.txt"
import path from "path" import path from "path"
+1 -1
View File
@@ -1,7 +1,7 @@
import z from "zod" import z from "zod"
import path from "path" import path from "path"
import { Effect } from "effect" import { Effect } from "effect"
import { Tool } from "./tool" import * as Tool from "./tool"
import { Question } from "../question" import { Question } from "../question"
import { Session } from "../session" import { Session } from "../session"
import { MessageV2 } from "../session/message-v2" import { MessageV2 } from "../session/message-v2"
+1 -1
View File
@@ -1,6 +1,6 @@
import z from "zod" import z from "zod"
import { Effect } from "effect" import { Effect } from "effect"
import { Tool } from "./tool" import * as Tool from "./tool"
import { Question } from "../question" import { Question } from "../question"
import DESCRIPTION from "./question.txt" import DESCRIPTION from "./question.txt"
+1 -1
View File
@@ -4,7 +4,7 @@ import { createReadStream } from "fs"
import { open } from "fs/promises" import { open } from "fs/promises"
import * as path from "path" import * as path from "path"
import { createInterface } from "readline" import { createInterface } from "readline"
import { Tool } from "./tool" import * as Tool from "./tool"
import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { LSP } from "../lsp" import { LSP } from "../lsp"
import { FileTime } from "../file/time" import { FileTime } from "../file/time"
+273 -275
View File
@@ -12,7 +12,7 @@ import { WebFetchTool } from "./webfetch"
import { WriteTool } from "./write" import { WriteTool } from "./write"
import { InvalidTool } from "./invalid" import { InvalidTool } from "./invalid"
import { SkillTool } from "./skill" import { SkillTool } from "./skill"
import { Tool } from "./tool" import * as Tool from "./tool"
import { Config } from "../config" import { Config } from "../config"
import { type ToolContext as PluginToolContext, type ToolDefinition } from "@opencode-ai/plugin" import { type ToolContext as PluginToolContext, type ToolDefinition } from "@opencode-ai/plugin"
import z from "zod" import z from "zod"
@@ -24,7 +24,7 @@ import { CodeSearchTool } from "./codesearch"
import { Flag } from "@/flag/flag" import { Flag } from "@/flag/flag"
import { Log } from "@/util" import { Log } from "@/util"
import { LspTool } from "./lsp" import { LspTool } from "./lsp"
import { Truncate } from "./truncate" import * as Truncate from "./truncate"
import { ApplyPatchTool } from "./apply_patch" import { ApplyPatchTool } from "./apply_patch"
import { Glob } from "@opencode-ai/shared/util/glob" import { Glob } from "@opencode-ai/shared/util/glob"
import path from "path" import path from "path"
@@ -47,299 +47,297 @@ import { Agent } from "../agent/agent"
import { Skill } from "../skill" import { Skill } from "../skill"
import { Permission } from "@/permission" 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<typeof TaskTool> type TaskDef = Tool.InferDef<typeof TaskTool>
type ReadDef = Tool.InferDef<typeof ReadTool> type ReadDef = Tool.InferDef<typeof ReadTool>
type State = { type State = {
custom: Tool.Def[] custom: Tool.Def[]
builtin: Tool.Def[] builtin: Tool.Def[]
task: TaskDef task: TaskDef
read: ReadDef read: ReadDef
} }
export interface Interface { export interface Interface {
readonly ids: () => Effect.Effect<string[]> readonly ids: () => Effect.Effect<string[]>
readonly all: () => Effect.Effect<Tool.Def[]> readonly all: () => Effect.Effect<Tool.Def[]>
readonly named: () => Effect.Effect<{ task: TaskDef; read: ReadDef }> readonly named: () => Effect.Effect<{ task: TaskDef; read: ReadDef }>
readonly tools: (model: { readonly tools: (model: {
providerID: ProviderID providerID: ProviderID
modelID: ModelID modelID: ModelID
agent: Agent.Info agent: Agent.Info
}) => Effect.Effect<Tool.Def[]> }) => Effect.Effect<Tool.Def[]>
} }
export class Service extends Context.Service<Service, Interface>()("@opencode/ToolRegistry") {} export class Service extends Context.Service<Service, Interface>()("@opencode/ToolRegistry") {}
export const layer: Layer.Layer< export const layer: Layer.Layer<
Service, Service,
never, never,
| Config.Service | Config.Service
| Plugin.Service | Plugin.Service
| Question.Service | Question.Service
| Todo.Service | Todo.Service
| Agent.Service | Agent.Service
| Skill.Service | Skill.Service
| Session.Service | Session.Service
| Provider.Service | Provider.Service
| LSP.Service | LSP.Service
| FileTime.Service | FileTime.Service
| Instruction.Service | Instruction.Service
| AppFileSystem.Service | AppFileSystem.Service
| Bus.Service | Bus.Service
| HttpClient.HttpClient | HttpClient.HttpClient
| ChildProcessSpawner | ChildProcessSpawner
| Ripgrep.Service | Ripgrep.Service
| Format.Service | Format.Service
| Truncate.Service | Truncate.Service
> = Layer.effect( > = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const config = yield* Config.Service const config = yield* Config.Service
const plugin = yield* Plugin.Service const plugin = yield* Plugin.Service
const agents = yield* Agent.Service const agents = yield* Agent.Service
const skill = yield* Skill.Service const skill = yield* Skill.Service
const truncate = yield* Truncate.Service const truncate = yield* Truncate.Service
const invalid = yield* InvalidTool const invalid = yield* InvalidTool
const task = yield* TaskTool const task = yield* TaskTool
const read = yield* ReadTool const read = yield* ReadTool
const question = yield* QuestionTool const question = yield* QuestionTool
const todo = yield* TodoWriteTool const todo = yield* TodoWriteTool
const lsptool = yield* LspTool const lsptool = yield* LspTool
const plan = yield* PlanExitTool const plan = yield* PlanExitTool
const webfetch = yield* WebFetchTool const webfetch = yield* WebFetchTool
const websearch = yield* WebSearchTool const websearch = yield* WebSearchTool
const bash = yield* BashTool const bash = yield* BashTool
const codesearch = yield* CodeSearchTool const codesearch = yield* CodeSearchTool
const globtool = yield* GlobTool const globtool = yield* GlobTool
const writetool = yield* WriteTool const writetool = yield* WriteTool
const edit = yield* EditTool const edit = yield* EditTool
const greptool = yield* GrepTool const greptool = yield* GrepTool
const patchtool = yield* ApplyPatchTool const patchtool = yield* ApplyPatchTool
const skilltool = yield* SkillTool const skilltool = yield* SkillTool
const agent = yield* Agent.Service const agent = yield* Agent.Service
const state = yield* InstanceState.make<State>( const state = yield* InstanceState.make<State>(
Effect.fn("ToolRegistry.state")(function* (ctx) { Effect.fn("ToolRegistry.state")(function* (ctx) {
const custom: Tool.Def[] = [] 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,
},
}
}),
}
}
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),
)
for (const [id, def] of Object.entries<ToolDefinition>(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))
}
}
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),
})
function fromPlugin(id: string, def: ToolDefinition): Tool.Def {
return { return {
custom, id,
builtin: [ parameters: z.object(def.args),
tool.invalid, description: def.description,
...(questionEnabled ? [tool.question] : []), execute: (args, toolCtx) =>
tool.bash, Effect.gen(function* () {
tool.read, const pluginCtx: PluginToolContext = {
tool.glob, ...toolCtx,
tool.grep, ask: (req) => toolCtx.ask(req),
tool.edit, directory: ctx.directory,
tool.write, worktree: ctx.worktree,
tool.task, }
tool.fetch, const result = yield* Effect.promise(() => def.execute(args as any, pluginCtx))
tool.todo, const info = yield* agent.get(toolCtx.agent)
tool.search, const out = yield* truncate.output(result, {}, info)
tool.code, return {
tool.skill, title: "",
tool.patch, output: out.truncated ? out.content : result,
...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [tool.lsp] : []), metadata: {
...(Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE && Flag.OPENCODE_CLIENT === "cli" ? [tool.plan] : []), truncated: out.truncated,
], outputPath: out.truncated ? out.outputPath : undefined,
task: tool.task, },
read: tool.read, }
}),
} }
}), }
)
const all: Interface["all"] = Effect.fn("ToolRegistry.all")(function* () { const dirs = yield* config.directories()
const s = yield* InstanceState.get(state) const matches = dirs.flatMap((dir) =>
return [...s.builtin, ...s.custom] as Tool.Def[] Glob.scanSync("{tool,tools}/*.{js,ts}", { cwd: dir, absolute: true, dot: true, symlink: true }),
})
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 `<skill_content name="...">` block with the loaded content.',
"",
"The following skills provide specialized sets of instructions for particular tasks",
"Invoke this tool to load a skill when a task matches one of the available skills listed below:",
"",
Skill.fmt(list, { verbose: false }),
].join("\n")
})
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)) if (matches.length) yield* config.waitForDependencies()
const description = list for (const match of matches) {
.map( const namespace = path.basename(match, path.extname(match))
(item) => const mod = yield* Effect.promise(
`- ${item.name}: ${item.description ?? "This subagent should only be called manually by the user."}`, () => import(process.platform === "win32" ? match : pathToFileURL(match).href),
) )
.join("\n") for (const [id, def] of Object.entries<ToolDefinition>(mod)) {
return ["Available agent types and the tools they have access to:", description].join("\n") custom.push(fromPlugin(id === "default" ? namespace : `${namespace}_${id}`, def))
})
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 = const plugins = yield* plugin.list()
input.modelID.includes("gpt-") && !input.modelID.includes("oss") && !input.modelID.includes("gpt-4") for (const p of plugins) {
if (tool.id === ApplyPatchTool.id) return usePatch for (const [id, def] of Object.entries(p.tool ?? {})) {
if (tool.id === EditTool.id || tool.id === WriteTool.id) return !usePatch custom.push(fromPlugin(id, def))
}
}
return true 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),
}) })
return yield* Effect.forEach( return {
filtered, custom,
Effect.fnUntraced(function* (tool: Tool.Def) { builtin: [
using _ = log.time(tool.id) tool.invalid,
const output = { ...(questionEnabled ? [tool.question] : []),
description: tool.description, tool.bash,
parameters: tool.parameters, tool.read,
} tool.glob,
yield* plugin.trigger("tool.definition", { toolID: tool.id }, output) tool.grep,
return { tool.edit,
id: tool.id, tool.write,
description: [ tool.task,
output.description, tool.fetch,
tool.id === TaskTool.id ? yield* describeTask(input.agent) : undefined, tool.todo,
tool.id === SkillTool.id ? yield* describeSkill(input.agent) : undefined, tool.search,
] tool.code,
.filter(Boolean) tool.skill,
.join("\n"), tool.patch,
parameters: output.parameters, ...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [tool.lsp] : []),
execute: tool.execute, ...(Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE && Flag.OPENCODE_CLIENT === "cli" ? [tool.plan] : []),
formatValidationError: tool.formatValidationError, ],
} task: tool.task,
}), read: tool.read,
{ concurrency: "unbounded" }, }
}),
)
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 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 `<skill_content name="...">` block with the loaded content.',
"",
"The following skills provide specialized sets of instructions for particular tasks",
"Invoke this tool to load a skill when a task matches one of the available skills listed below:",
"",
Skill.fmt(list, { verbose: false }),
].join("\n")
})
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."}`,
) )
.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
}
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 true
}) })
const named: Interface["named"] = Effect.fn("ToolRegistry.named")(function* () { return yield* Effect.forEach(
const s = yield* InstanceState.get(state) filtered,
return { task: s.task, read: s.read } 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(() => return Service.of({ ids, all, named, tools })
layer.pipe( }),
Layer.provide(Config.defaultLayer), )
Layer.provide(Plugin.defaultLayer),
Layer.provide(Question.defaultLayer), export const defaultLayer = Layer.suspend(() =>
Layer.provide(Todo.defaultLayer), layer.pipe(
Layer.provide(Skill.defaultLayer), Layer.provide(Config.defaultLayer),
Layer.provide(Agent.defaultLayer), Layer.provide(Plugin.defaultLayer),
Layer.provide(Session.defaultLayer), Layer.provide(Question.defaultLayer),
Layer.provide(Provider.defaultLayer), Layer.provide(Todo.defaultLayer),
Layer.provide(LSP.defaultLayer), Layer.provide(Skill.defaultLayer),
Layer.provide(FileTime.defaultLayer), Layer.provide(Agent.defaultLayer),
Layer.provide(Instruction.defaultLayer), Layer.provide(Session.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Provider.defaultLayer),
Layer.provide(Bus.layer), Layer.provide(LSP.defaultLayer),
Layer.provide(FetchHttpClient.layer), Layer.provide(FileTime.defaultLayer),
Layer.provide(Format.defaultLayer), Layer.provide(Instruction.defaultLayer),
Layer.provide(CrossSpawnSpawner.defaultLayer), Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Ripgrep.defaultLayer), Layer.provide(Bus.layer),
Layer.provide(Truncate.defaultLayer), Layer.provide(FetchHttpClient.layer),
), Layer.provide(Format.defaultLayer),
) Layer.provide(CrossSpawnSpawner.defaultLayer),
} Layer.provide(Ripgrep.defaultLayer),
Layer.provide(Truncate.defaultLayer),
),
)
+1 -1
View File
@@ -6,7 +6,7 @@ import * as Stream from "effect/Stream"
import { EffectLogger } from "@/effect" import { EffectLogger } from "@/effect"
import { Ripgrep } from "../file/ripgrep" import { Ripgrep } from "../file/ripgrep"
import { Skill } from "../skill" import { Skill } from "../skill"
import { Tool } from "./tool" import * as Tool from "./tool"
const Parameters = z.object({ const Parameters = z.object({
name: z.string().describe("The name of the skill from available_skills"), name: z.string().describe("The name of the skill from available_skills"),
+1 -1
View File
@@ -1,4 +1,4 @@
import { Tool } from "./tool" import * as Tool from "./tool"
import DESCRIPTION from "./task.txt" import DESCRIPTION from "./task.txt"
import z from "zod" import z from "zod"
import { Session } from "../session" import { Session } from "../session"
+1 -1
View File
@@ -1,6 +1,6 @@
import z from "zod" import z from "zod"
import { Effect } from "effect" import { Effect } from "effect"
import { Tool } from "./tool" import * as Tool from "./tool"
import DESCRIPTION_WRITE from "./todowrite.txt" import DESCRIPTION_WRITE from "./todowrite.txt"
import { Todo } from "../session/todo" import { Todo } from "../session/todo"
+124 -126
View File
@@ -3,146 +3,144 @@ import { Effect } from "effect"
import type { MessageV2 } from "../session/message-v2" import type { MessageV2 } from "../session/message-v2"
import type { Permission } from "../permission" import type { Permission } from "../permission"
import type { SessionID, MessageID } from "../session/schema" import type { SessionID, MessageID } from "../session/schema"
import { Truncate } from "./truncate" import * as Truncate from "./truncate"
import { Agent } from "@/agent/agent" import { Agent } from "@/agent/agent"
export namespace Tool { interface Metadata {
interface Metadata { [key: string]: any
[key: string]: any }
}
// TODO: remove this hack // TODO: remove this hack
export type DynamicDescription = (agent: Agent.Info) => Effect.Effect<string> export type DynamicDescription = (agent: Agent.Info) => Effect.Effect<string>
export type Context<M extends Metadata = Metadata> = { export type Context<M extends Metadata = Metadata> = {
sessionID: SessionID sessionID: SessionID
messageID: MessageID messageID: MessageID
agent: string agent: string
abort: AbortSignal abort: AbortSignal
callID?: string callID?: string
extra?: { [key: string]: any } extra?: { [key: string]: any }
messages: MessageV2.WithParts[] messages: MessageV2.WithParts[]
metadata(input: { title?: string; metadata?: M }): Effect.Effect<void> metadata(input: { title?: string; metadata?: M }): Effect.Effect<void>
ask(input: Omit<Permission.Request, "id" | "sessionID" | "tool">): Effect.Effect<void> ask(input: Omit<Permission.Request, "id" | "sessionID" | "tool">): Effect.Effect<void>
} }
export interface ExecuteResult<M extends Metadata = Metadata> { export interface ExecuteResult<M extends Metadata = Metadata> {
title: string title: string
metadata: M metadata: M
output: string output: string
attachments?: Omit<MessageV2.FilePart, "id" | "sessionID" | "messageID">[] attachments?: Omit<MessageV2.FilePart, "id" | "sessionID" | "messageID">[]
} }
export interface Def<Parameters extends z.ZodType = z.ZodType, M extends Metadata = Metadata> { export interface Def<Parameters extends z.ZodType = z.ZodType, M extends Metadata = Metadata> {
id: string id: string
description: string description: string
parameters: Parameters parameters: Parameters
execute(args: z.infer<Parameters>, ctx: Context): Effect.Effect<ExecuteResult<M>> execute(args: z.infer<Parameters>, ctx: Context): Effect.Effect<ExecuteResult<M>>
formatValidationError?(error: z.ZodError): string formatValidationError?(error: z.ZodError): string
} }
export type DefWithoutID<Parameters extends z.ZodType = z.ZodType, M extends Metadata = Metadata> = Omit< export type DefWithoutID<Parameters extends z.ZodType = z.ZodType, M extends Metadata = Metadata> = Omit<
Def<Parameters, M>, Def<Parameters, M>,
"id" "id"
> >
export interface Info<Parameters extends z.ZodType = z.ZodType, M extends Metadata = Metadata> { export interface Info<Parameters extends z.ZodType = z.ZodType, M extends Metadata = Metadata> {
id: string id: string
init: () => Effect.Effect<DefWithoutID<Parameters, M>> init: () => Effect.Effect<DefWithoutID<Parameters, M>>
} }
type Init<Parameters extends z.ZodType, M extends Metadata> = type Init<Parameters extends z.ZodType, M extends Metadata> =
| DefWithoutID<Parameters, M> | DefWithoutID<Parameters, M>
| (() => Effect.Effect<DefWithoutID<Parameters, M>>) | (() => Effect.Effect<DefWithoutID<Parameters, M>>)
export type InferParameters<T> = export type InferParameters<T> =
T extends Info<infer P, any> T extends Info<infer P, any>
? z.infer<P>
: T extends Effect.Effect<Info<infer P, any>, any, any>
? z.infer<P> ? z.infer<P>
: T extends Effect.Effect<Info<infer P, any>, any, any> : never
? z.infer<P> export type InferMetadata<T> =
: never T extends Info<any, infer M> ? M : T extends Effect.Effect<Info<any, infer M>, any, any> ? M : never
export type InferMetadata<T> =
T extends Info<any, infer M> ? M : T extends Effect.Effect<Info<any, infer M>, any, any> ? M : never
export type InferDef<T> = export type InferDef<T> =
T extends Info<infer P, infer M> T extends Info<infer P, infer M>
? Def<P, M>
: T extends Effect.Effect<Info<infer P, infer M>, any, any>
? Def<P, M> ? Def<P, M>
: T extends Effect.Effect<Info<infer P, infer M>, any, any> : never
? Def<P, M>
: never
function wrap<Parameters extends z.ZodType, Result extends Metadata>( function wrap<Parameters extends z.ZodType, Result extends Metadata>(
id: string, id: string,
init: Init<Parameters, Result>, init: Init<Parameters, Result>,
truncate: Truncate.Interface, truncate: Truncate.Interface,
agents: Agent.Interface, agents: Agent.Interface,
) { ) {
return () => return () =>
Effect.gen(function* () { Effect.gen(function* () {
const toolInfo = typeof init === "function" ? { ...(yield* init()) } : { ...init } const toolInfo = typeof init === "function" ? { ...(yield* init()) } : { ...init }
const execute = toolInfo.execute const execute = toolInfo.execute
toolInfo.execute = (args, ctx) => { toolInfo.execute = (args, ctx) => {
const attrs = { const attrs = {
"tool.name": id, "tool.name": id,
"session.id": ctx.sessionID, "session.id": ctx.sessionID,
"message.id": ctx.messageID, "message.id": ctx.messageID,
...(ctx.callID ? { "tool.call_id": ctx.callID } : {}), ...(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 }),
},
} }
return Effect.gen(function* () { }).pipe(Effect.orDie, Effect.withSpan("Tool.execute", { attributes: attrs }))
yield* Effect.try({ }
try: () => toolInfo.parameters.parse(args), return toolInfo
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<Parameters extends z.ZodType, Result extends Metadata, R, ID extends string = string>( export function define<Parameters extends z.ZodType, Result extends Metadata, R, ID extends string = string>(
id: ID, id: ID,
init: Effect.Effect<Init<Parameters, Result>, never, R>, init: Effect.Effect<Init<Parameters, Result>, never, R>,
): Effect.Effect<Info<Parameters, Result>, never, R | Truncate.Service | Agent.Service> & { id: ID } { ): Effect.Effect<Info<Parameters, Result>, never, R | Truncate.Service | Agent.Service> & { id: ID } {
return Object.assign( return Object.assign(
Effect.gen(function* () { Effect.gen(function* () {
const resolved = yield* init const resolved = yield* init
const truncate = yield* Truncate.Service const truncate = yield* Truncate.Service
const agents = yield* Agent.Service const agents = yield* Agent.Service
return { id, init: wrap(id, resolved, truncate, agents) } return { id, init: wrap(id, resolved, truncate, agents) }
}), }),
{ id }, { id },
) )
} }
export function init<P extends z.ZodType, M extends Metadata>(info: Info<P, M>): Effect.Effect<Def<P, M>> { export function init<P extends z.ZodType, M extends Metadata>(info: Info<P, M>): Effect.Effect<Def<P, M>> {
return Effect.gen(function* () { return Effect.gen(function* () {
const init = yield* info.init() const init = yield* info.init()
return { return {
...init, ...init,
id: info.id, id: info.id,
} }
}) })
}
} }
+127 -129
View File
@@ -9,136 +9,134 @@ import { Log } from "../util"
import { ToolID } from "./schema" import { ToolID } from "./schema"
import { TRUNCATION_DIR } from "./truncation-dir" import { TRUNCATION_DIR } from "./truncation-dir"
export namespace Truncate { const log = Log.create({ service: "truncation" })
const log = Log.create({ service: "truncation" }) const RETENTION = Duration.days(7)
const RETENTION = Duration.days(7)
export const MAX_LINES = 2000 export const MAX_LINES = 2000
export const MAX_BYTES = 50 * 1024 export const MAX_BYTES = 50 * 1024
export const DIR = TRUNCATION_DIR export const DIR = TRUNCATION_DIR
export const GLOB = path.join(TRUNCATION_DIR, "*") export const GLOB = path.join(TRUNCATION_DIR, "*")
export type Result = { content: string; truncated: false } | { content: string; truncated: true; outputPath: string } export type Result = { content: string; truncated: false } | { content: string; truncated: true; outputPath: string }
export interface Options { export interface Options {
maxLines?: number maxLines?: number
maxBytes?: number maxBytes?: number
direction?: "head" | "tail" 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<void>
readonly write: (text: string) => Effect.Effect<string>
/**
* 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<Result>
}
export class Service extends Context.Service<Service, Interface>()("@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))
} }
function hasTaskTool(agent?: Agent.Info) {
if (!agent?.permission) return false
return evaluate("task", "*", agent.permission).action !== "deny"
}
export interface Interface {
readonly cleanup: () => Effect.Effect<void>
readonly write: (text: string) => Effect.Effect<string>
/**
* 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<Result>
}
export class Service extends Context.Service<Service, Interface>()("@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))
+1 -1
View File
@@ -1,7 +1,7 @@
import z from "zod" import z from "zod"
import { Effect } from "effect" import { Effect } from "effect"
import { HttpClient, HttpClientRequest } from "effect/unstable/http" import { HttpClient, HttpClientRequest } from "effect/unstable/http"
import { Tool } from "./tool" import * as Tool from "./tool"
import TurndownService from "turndown" import TurndownService from "turndown"
import DESCRIPTION from "./webfetch.txt" import DESCRIPTION from "./webfetch.txt"
+1 -1
View File
@@ -1,7 +1,7 @@
import z from "zod" import z from "zod"
import { Effect } from "effect" import { Effect } from "effect"
import { HttpClient } from "effect/unstable/http" import { HttpClient } from "effect/unstable/http"
import { Tool } from "./tool" import * as Tool from "./tool"
import * as McpExa from "./mcp-exa" import * as McpExa from "./mcp-exa"
import DESCRIPTION from "./websearch.txt" import DESCRIPTION from "./websearch.txt"
+1 -1
View File
@@ -1,7 +1,7 @@
import z from "zod" import z from "zod"
import * as path from "path" import * as path from "path"
import { Effect } from "effect" import { Effect } from "effect"
import { Tool } from "./tool" import * as Tool from "./tool"
import { LSP } from "../lsp" import { LSP } from "../lsp"
import { createTwoFilesPatch } from "diff" import { createTwoFilesPatch } from "diff"
import DESCRIPTION from "./write.txt" import DESCRIPTION from "./write.txt"
+4 -4
View File
@@ -84,7 +84,7 @@ test("explore agent denies edit and write", async () => {
}) })
test("explore agent asks for external directories and allows Truncate.GLOB", 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 using tmp = await tmpdir()
await Instance.provide({ await Instance.provide({
directory: tmp.path, 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 () => { 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({ await using tmp = await tmpdir({
config: { config: {
permission: { 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 () => { 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({ await using tmp = await tmpdir({
config: { config: {
agent: { 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 () => { 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({ await using tmp = await tmpdir({
config: { config: {
permission: { permission: {
@@ -34,8 +34,8 @@ import { Skill } from "../../src/skill"
import { SystemPrompt } from "../../src/session/system" import { SystemPrompt } from "../../src/session/system"
import { Shell } from "../../src/shell/shell" import { Shell } from "../../src/shell/shell"
import { Snapshot } from "../../src/snapshot" import { Snapshot } from "../../src/snapshot"
import { ToolRegistry } from "../../src/tool/registry" import { ToolRegistry } from "../../src/tool"
import { Truncate } from "../../src/tool/truncate" import { Truncate } from "../../src/tool"
import { Log } from "../../src/util" import { Log } from "../../src/util"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
import { Ripgrep } from "../../src/file/ripgrep" import { Ripgrep } from "../../src/file/ripgrep"
@@ -50,8 +50,8 @@ import { SessionProcessor } from "../../src/session/processor"
import { SessionRunState } from "../../src/session/run-state" import { SessionRunState } from "../../src/session/run-state"
import { SessionStatus } from "../../src/session/status" import { SessionStatus } from "../../src/session/status"
import { Snapshot } from "../../src/snapshot" import { Snapshot } from "../../src/snapshot"
import { ToolRegistry } from "../../src/tool/registry" import { ToolRegistry } from "../../src/tool"
import { Truncate } from "../../src/tool/truncate" import { Truncate } from "../../src/tool"
import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
import { Ripgrep } from "../../src/file/ripgrep" import { Ripgrep } from "../../src/file/ripgrep"
@@ -9,7 +9,7 @@ import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { Format } from "../../src/format" import { Format } from "../../src/format"
import { Agent } from "../../src/agent/agent" import { Agent } from "../../src/agent/agent"
import { Bus } from "../../src/bus" import { Bus } from "../../src/bus"
import { Truncate } from "../../src/tool/truncate" import { Truncate } from "../../src/tool"
import { tmpdir } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture"
import { SessionID, MessageID } from "../../src/session/schema" import { SessionID, MessageID } from "../../src/session/schema"
+1 -1
View File
@@ -9,7 +9,7 @@ import { Filesystem } from "../../src/util"
import { tmpdir } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture"
import type { Permission } from "../../src/permission" import type { Permission } from "../../src/permission"
import { Agent } from "../../src/agent/agent" 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 { SessionID, MessageID } from "../../src/session/schema"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { AppFileSystem } from "@opencode-ai/shared/filesystem"
+1 -1
View File
@@ -12,7 +12,7 @@ import { Format } from "../../src/format"
import { Agent } from "../../src/agent/agent" import { Agent } from "../../src/agent/agent"
import { Bus } from "../../src/bus" import { Bus } from "../../src/bus"
import { BusEvent } from "../../src/bus/bus-event" 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" import { SessionID, MessageID } from "../../src/session/schema"
const ctx = { const ctx = {
@@ -1,7 +1,7 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import path from "path" import path from "path"
import { Effect } from "effect" import { Effect } from "effect"
import type { Tool } from "../../src/tool/tool" import type { Tool } from "../../src/tool"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { assertExternalDirectory } from "../../src/tool/external-directory" import { assertExternalDirectory } from "../../src/tool/external-directory"
import { Filesystem } from "../../src/util" import { Filesystem } from "../../src/util"
+1 -1
View File
@@ -6,7 +6,7 @@ import { SessionID, MessageID } from "../../src/session/schema"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
import { Ripgrep } from "../../src/file/ripgrep" import { Ripgrep } from "../../src/file/ripgrep"
import { AppFileSystem } from "@opencode-ai/shared/filesystem" 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 { Agent } from "../../src/agent/agent"
import { provideTmpdirInstance } from "../fixture/fixture" import { provideTmpdirInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect" import { testEffect } from "../lib/effect"
+1 -1
View File
@@ -5,7 +5,7 @@ import { GrepTool } from "../../src/tool/grep"
import { provideInstance, provideTmpdirInstance } from "../fixture/fixture" import { provideInstance, provideTmpdirInstance } from "../fixture/fixture"
import { SessionID, MessageID } from "../../src/session/schema" import { SessionID, MessageID } from "../../src/session/schema"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" 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 { Agent } from "../../src/agent/agent"
import { Ripgrep } from "../../src/file/ripgrep" import { Ripgrep } from "../../src/file/ripgrep"
import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { AppFileSystem } from "@opencode-ai/shared/filesystem"
+1 -1
View File
@@ -5,7 +5,7 @@ import { Question } from "../../src/question"
import { SessionID, MessageID } from "../../src/session/schema" import { SessionID, MessageID } from "../../src/session/schema"
import { Agent } from "../../src/agent/agent" import { Agent } from "../../src/agent/agent"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" 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 { provideTmpdirInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect" import { testEffect } from "../lib/effect"
+2 -2
View File
@@ -11,8 +11,8 @@ import { Instance } from "../../src/project/instance"
import { SessionID, MessageID } from "../../src/session/schema" import { SessionID, MessageID } from "../../src/session/schema"
import { Instruction } from "../../src/session/instruction" import { Instruction } from "../../src/session/instruction"
import { ReadTool } from "../../src/tool/read" import { ReadTool } from "../../src/tool/read"
import { Truncate } from "../../src/tool/truncate" import { Truncate } from "../../src/tool"
import { Tool } from "../../src/tool/tool" import { Tool } from "../../src/tool"
import { Filesystem } from "../../src/util" import { Filesystem } from "../../src/util"
import { provideInstance, tmpdirScoped } from "../fixture/fixture" import { provideInstance, tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect" import { testEffect } from "../lib/effect"
+1 -1
View File
@@ -4,7 +4,7 @@ import fs from "fs/promises"
import { Effect, Layer } from "effect" import { Effect, Layer } from "effect"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" 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 { provideTmpdirInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect" import { testEffect } from "../lib/effect"
+2 -2
View File
@@ -4,10 +4,10 @@ import { afterEach, describe, expect } from "bun:test"
import path from "path" import path from "path"
import { pathToFileURL } from "url" import { pathToFileURL } from "url"
import type { Permission } from "../../src/permission" 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 { Instance } from "../../src/project/instance"
import { SkillTool } from "../../src/tool/skill" import { SkillTool } from "../../src/tool/skill"
import { ToolRegistry } from "../../src/tool/registry" import { ToolRegistry } from "../../src/tool"
import { provideTmpdirInstance } from "../fixture/fixture" import { provideTmpdirInstance } from "../fixture/fixture"
import { SessionID, MessageID } from "../../src/session/schema" import { SessionID, MessageID } from "../../src/session/schema"
import { testEffect } from "../lib/effect" import { testEffect } from "../lib/effect"
+2 -2
View File
@@ -10,8 +10,8 @@ import type { SessionPrompt } from "../../src/session/prompt"
import { MessageID, PartID } from "../../src/session/schema" import { MessageID, PartID } from "../../src/session/schema"
import { ModelID, ProviderID } from "../../src/provider/schema" import { ModelID, ProviderID } from "../../src/provider/schema"
import { TaskTool, type TaskPromptOps } from "../../src/tool/task" import { TaskTool, type TaskPromptOps } from "../../src/tool/task"
import { Truncate } from "../../src/tool/truncate" import { Truncate } from "../../src/tool"
import { ToolRegistry } from "../../src/tool/registry" import { ToolRegistry } from "../../src/tool"
import { provideTmpdirInstance } from "../fixture/fixture" import { provideTmpdirInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect" import { testEffect } from "../lib/effect"
@@ -2,8 +2,8 @@ import { describe, test, expect } from "bun:test"
import { Effect, Layer, ManagedRuntime } from "effect" import { Effect, Layer, ManagedRuntime } from "effect"
import z from "zod" import z from "zod"
import { Agent } from "../../src/agent/agent" import { Agent } from "../../src/agent/agent"
import { Tool } from "../../src/tool/tool" import { Tool } from "../../src/tool"
import { Truncate } from "../../src/tool/truncate" import { Truncate } from "../../src/tool"
const runtime = ManagedRuntime.make(Layer.mergeAll(Truncate.defaultLayer, Agent.defaultLayer)) const runtime = ManagedRuntime.make(Layer.mergeAll(Truncate.defaultLayer, Agent.defaultLayer))
@@ -1,7 +1,7 @@
import { describe, test, expect } from "bun:test" import { describe, test, expect } from "bun:test"
import { NodeFileSystem } from "@effect/platform-node" import { NodeFileSystem } from "@effect/platform-node"
import { Effect, FileSystem, Layer } from "effect" import { Effect, FileSystem, Layer } from "effect"
import { Truncate } from "../../src/tool/truncate" import { Truncate } from "../../src/tool"
import { Identifier } from "../../src/id/id" import { Identifier } from "../../src/id/id"
import { Process } from "../../src/util" import { Process } from "../../src/util"
import { Filesystem } from "../../src/util" import { Filesystem } from "../../src/util"
+1 -1
View File
@@ -3,7 +3,7 @@ import path from "path"
import { Effect, Layer } from "effect" import { Effect, Layer } from "effect"
import { FetchHttpClient } from "effect/unstable/http" import { FetchHttpClient } from "effect/unstable/http"
import { Agent } from "../../src/agent/agent" import { Agent } from "../../src/agent/agent"
import { Truncate } from "../../src/tool/truncate" import { Truncate } from "../../src/tool"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { WebFetchTool } from "../../src/tool/webfetch" import { WebFetchTool } from "../../src/tool/webfetch"
import { SessionID, MessageID } from "../../src/session/schema" import { SessionID, MessageID } from "../../src/session/schema"
+2 -2
View File
@@ -9,8 +9,8 @@ import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { FileTime } from "../../src/file/time" import { FileTime } from "../../src/file/time"
import { Bus } from "../../src/bus" import { Bus } from "../../src/bus"
import { Format } from "../../src/format" import { Format } from "../../src/format"
import { Truncate } from "../../src/tool/truncate" import { Truncate } from "../../src/tool"
import { Tool } from "../../src/tool/tool" import { Tool } from "../../src/tool"
import { Agent } from "../../src/agent/agent" import { Agent } from "../../src/agent/agent"
import { SessionID, MessageID } from "../../src/session/schema" import { SessionID, MessageID } from "../../src/session/schema"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"