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

This commit is contained in:
Kit Langton
2026-04-15 23:56:54 -04:00
committed by GitHub
parent f6cc228684
commit 5011465c81
48 changed files with 599 additions and 602 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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",

View File

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

View File

@@ -0,0 +1,3 @@
export * as Truncate from "./truncate"
export * as ToolRegistry from "./registry"
export * as Tool from "./tool"

View File

@@ -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",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<typeof TaskTool>
type ReadDef = Tool.InferDef<typeof ReadTool>
type TaskDef = Tool.InferDef<typeof TaskTool>
type ReadDef = Tool.InferDef<typeof ReadTool>
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<string[]>
readonly all: () => Effect.Effect<Tool.Def[]>
readonly named: () => Effect.Effect<{ task: TaskDef; read: ReadDef }>
readonly tools: (model: {
providerID: ProviderID
modelID: ModelID
agent: Agent.Info
}) => Effect.Effect<Tool.Def[]>
}
export interface Interface {
readonly ids: () => Effect.Effect<string[]>
readonly all: () => Effect.Effect<Tool.Def[]>
readonly named: () => Effect.Effect<{ task: TaskDef; read: ReadDef }>
readonly tools: (model: {
providerID: ProviderID
modelID: ModelID
agent: Agent.Info
}) => 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<
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<State>(
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,
},
}
}),
}
}
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),
})
const state = yield* InstanceState.make<State>(
Effect.fn("ToolRegistry.state")(function* (ctx) {
const custom: Tool.Def[] = []
function fromPlugin(id: string, def: ToolDefinition): Tool.Def {
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,
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 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 dirs = yield* config.directories()
const matches = dirs.flatMap((dir) =>
Glob.scanSync("{tool,tools}/*.{js,ts}", { cwd: dir, absolute: true, dot: true, symlink: true }),
)
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."}`,
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),
)
.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
for (const [id, def] of Object.entries<ToolDefinition>(mod)) {
custom.push(fromPlugin(id === "default" ? namespace : `${namespace}_${id}`, def))
}
}
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 plugins = yield* plugin.list()
for (const p of plugins) {
for (const [id, def] of Object.entries(p.tool ?? {})) {
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(
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 {
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 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* () {
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),
),
)

View File

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

View File

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

View File

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

View File

@@ -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
}
// TODO: remove this hack
export type DynamicDescription = (agent: Agent.Info) => Effect.Effect<string>
export type Context<M extends Metadata = Metadata> = {
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<void>
ask(input: Omit<Permission.Request, "id" | "sessionID" | "tool">): Effect.Effect<void>
}
export interface ExecuteResult<M extends Metadata = Metadata> {
title: string
metadata: M
output: string
attachments?: Omit<MessageV2.FilePart, "id" | "sessionID" | "messageID">[]
}
export interface Def<Parameters extends z.ZodType = z.ZodType, M extends Metadata = Metadata> {
id: string
description: string
parameters: Parameters
execute(args: z.infer<Parameters>, ctx: Context): Effect.Effect<ExecuteResult<M>>
formatValidationError?(error: z.ZodError): string
}
export type DefWithoutID<Parameters extends z.ZodType = z.ZodType, M extends Metadata = Metadata> = Omit<
Def<Parameters, M>,
"id"
>
export interface Info<Parameters extends z.ZodType = z.ZodType, M extends Metadata = Metadata> {
id: string
init: () => Effect.Effect<DefWithoutID<Parameters, M>>
}
type Init<Parameters extends z.ZodType, M extends Metadata> =
| DefWithoutID<Parameters, M>
| (() => Effect.Effect<DefWithoutID<Parameters, M>>)
export type InferParameters<T> =
T extends Info<infer P, any>
? z.infer<P>
: T extends Effect.Effect<Info<infer P, any>, any, any>
? z.infer<P>
: 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> =
T extends Info<infer P, infer M>
? Def<P, M>
: T extends Effect.Effect<Info<infer P, infer M>, any, any>
? Def<P, M>
: never
function wrap<Parameters extends z.ZodType, Result extends Metadata>(
id: string,
init: Init<Parameters, Result>,
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 }))
}
return toolInfo
})
}
export function define<Parameters extends z.ZodType, Result extends Metadata, R, ID extends string = string>(
id: ID,
init: Effect.Effect<Init<Parameters, Result>, never, R>,
): Effect.Effect<Info<Parameters, Result>, 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<P extends z.ZodType, M extends Metadata>(info: Info<P, M>): Effect.Effect<Def<P, M>> {
return Effect.gen(function* () {
const init = yield* info.init()
return {
...init,
id: info.id,
}
})
}
interface Metadata {
[key: string]: any
}
// TODO: remove this hack
export type DynamicDescription = (agent: Agent.Info) => Effect.Effect<string>
export type Context<M extends Metadata = Metadata> = {
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<void>
ask(input: Omit<Permission.Request, "id" | "sessionID" | "tool">): Effect.Effect<void>
}
export interface ExecuteResult<M extends Metadata = Metadata> {
title: string
metadata: M
output: string
attachments?: Omit<MessageV2.FilePart, "id" | "sessionID" | "messageID">[]
}
export interface Def<Parameters extends z.ZodType = z.ZodType, M extends Metadata = Metadata> {
id: string
description: string
parameters: Parameters
execute(args: z.infer<Parameters>, ctx: Context): Effect.Effect<ExecuteResult<M>>
formatValidationError?(error: z.ZodError): string
}
export type DefWithoutID<Parameters extends z.ZodType = z.ZodType, M extends Metadata = Metadata> = Omit<
Def<Parameters, M>,
"id"
>
export interface Info<Parameters extends z.ZodType = z.ZodType, M extends Metadata = Metadata> {
id: string
init: () => Effect.Effect<DefWithoutID<Parameters, M>>
}
type Init<Parameters extends z.ZodType, M extends Metadata> =
| DefWithoutID<Parameters, M>
| (() => Effect.Effect<DefWithoutID<Parameters, M>>)
export type InferParameters<T> =
T extends Info<infer P, any>
? z.infer<P>
: T extends Effect.Effect<Info<infer P, any>, any, any>
? z.infer<P>
: 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> =
T extends Info<infer P, infer M>
? Def<P, M>
: T extends Effect.Effect<Info<infer P, infer M>, any, any>
? Def<P, M>
: never
function wrap<Parameters extends z.ZodType, Result extends Metadata>(
id: string,
init: Init<Parameters, Result>,
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 }))
}
return toolInfo
})
}
export function define<Parameters extends z.ZodType, Result extends Metadata, R, ID extends string = string>(
id: ID,
init: Effect.Effect<Init<Parameters, Result>, never, R>,
): Effect.Effect<Info<Parameters, Result>, 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<P extends z.ZodType, M extends Metadata>(info: Info<P, M>): Effect.Effect<Def<P, M>> {
return Effect.gen(function* () {
const init = yield* info.init()
return {
...init,
id: info.id,
}
})
}

View File

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

View File

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

View File

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

View File

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