refactor(effect): move tool descriptions into registry (#21795)

This commit is contained in:
Kit Langton
2026-04-09 22:20:27 -04:00
committed by GitHub
parent 16c60c9ee7
commit 17bd16667c
8 changed files with 87 additions and 54 deletions

View File

@@ -5,12 +5,12 @@ import { EditTool } from "./edit"
import { GlobTool } from "./glob" import { GlobTool } from "./glob"
import { GrepTool } from "./grep" import { GrepTool } from "./grep"
import { ReadTool } from "./read" import { ReadTool } from "./read"
import { TaskDescription, TaskTool } from "./task" import { TaskTool } from "./task"
import { TodoWriteTool } from "./todo" import { TodoWriteTool } from "./todo"
import { WebFetchTool } from "./webfetch" import { WebFetchTool } from "./webfetch"
import { WriteTool } from "./write" import { WriteTool } from "./write"
import { InvalidTool } from "./invalid" import { InvalidTool } from "./invalid"
import { SkillDescription, SkillTool } from "./skill" import { SkillTool } from "./skill"
import { Tool } from "./tool" import { Tool } from "./tool"
import { Config } from "../config/config" import { Config } from "../config/config"
import { type ToolContext as PluginToolContext, type ToolDefinition } from "@opencode-ai/plugin" import { type ToolContext as PluginToolContext, type ToolDefinition } from "@opencode-ai/plugin"
@@ -38,6 +38,8 @@ import { FileTime } from "../file/time"
import { Instruction } from "../session/instruction" import { Instruction } from "../session/instruction"
import { AppFileSystem } from "../filesystem" import { AppFileSystem } from "../filesystem"
import { Agent } from "../agent/agent" import { Agent } from "../agent/agent"
import { Skill } from "../skill"
import { Permission } from "@/permission"
export namespace ToolRegistry { export namespace ToolRegistry {
const log = Log.create({ service: "tool.registry" }) const log = Log.create({ service: "tool.registry" })
@@ -73,6 +75,7 @@ export namespace ToolRegistry {
| Question.Service | Question.Service
| Todo.Service | Todo.Service
| Agent.Service | Agent.Service
| Skill.Service
| LSP.Service | LSP.Service
| FileTime.Service | FileTime.Service
| Instruction.Service | Instruction.Service
@@ -82,6 +85,8 @@ export namespace ToolRegistry {
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 skill = yield* Skill.Service
const task = yield* TaskTool const task = yield* TaskTool
const read = yield* ReadTool const read = yield* ReadTool
@@ -199,6 +204,40 @@ export namespace ToolRegistry {
return (yield* all()).map((tool) => tool.id) 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 tools: Interface["tools"] = Effect.fn("ToolRegistry.tools")(function* (input) {
const filtered = (yield* all()).filter((tool) => { const filtered = (yield* all()).filter((tool) => {
if (tool.id === CodeSearchTool.id || tool.id === WebSearchTool.id) { if (tool.id === CodeSearchTool.id || tool.id === WebSearchTool.id) {
@@ -227,8 +266,8 @@ export namespace ToolRegistry {
id: tool.id, id: tool.id,
description: [ description: [
output.description, output.description,
tool.id === TaskTool.id ? yield* TaskDescription(input.agent) : undefined, tool.id === TaskTool.id ? yield* describeTask(input.agent) : undefined,
tool.id === SkillTool.id ? yield* SkillDescription(input.agent) : undefined, tool.id === SkillTool.id ? yield* describeSkill(input.agent) : undefined,
] ]
.filter(Boolean) .filter(Boolean)
.join("\n"), .join("\n"),
@@ -257,7 +296,9 @@ export namespace ToolRegistry {
Layer.provide(Plugin.defaultLayer), Layer.provide(Plugin.defaultLayer),
Layer.provide(Question.defaultLayer), Layer.provide(Question.defaultLayer),
Layer.provide(Todo.defaultLayer), Layer.provide(Todo.defaultLayer),
Layer.provide(Skill.defaultLayer),
Layer.provide(Agent.defaultLayer), Layer.provide(Agent.defaultLayer),
Layer.provide(Skill.defaultLayer),
Layer.provide(LSP.defaultLayer), Layer.provide(LSP.defaultLayer),
Layer.provide(FileTime.defaultLayer), Layer.provide(FileTime.defaultLayer),
Layer.provide(Instruction.defaultLayer), Layer.provide(Instruction.defaultLayer),

View File

@@ -1,4 +1,3 @@
import { Effect } from "effect"
import path from "path" import path from "path"
import { pathToFileURL } from "url" import { pathToFileURL } from "url"
import z from "zod" import z from "zod"
@@ -98,23 +97,3 @@ export const SkillTool = Tool.define("skill", async () => {
}, },
} }
}) })
export const SkillDescription: Tool.DynamicDescription = (agent) =>
Effect.gen(function* () {
const list = yield* Effect.promise(() => Skill.available(agent))
if (list.length === 0) return "No skills are currently available."
return [
"Load a specialized skill that provides domain-specific instructions and workflows.",
"",
"When you recognize that a task matches one of the available skills listed below, use this tool to load the full skill instructions.",
"",
"The skill will inject detailed instructions, workflows, and access to bundled resources (scripts, references, templates) into the conversation context.",
"",
'Tool output includes a `<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")
})

View File

@@ -7,7 +7,6 @@ import { MessageV2 } from "../session/message-v2"
import { Agent } from "../agent/agent" import { Agent } from "../agent/agent"
import { SessionPrompt } from "../session/prompt" import { SessionPrompt } from "../session/prompt"
import { Config } from "../config/config" import { Config } from "../config/config"
import { Permission } from "@/permission"
import { Effect } from "effect" import { Effect } from "effect"
import { Log } from "@/util/log" import { Log } from "@/util/log"
@@ -176,18 +175,3 @@ export const TaskTool = Tool.defineEffect(
} }
}), }),
) )
export const TaskDescription: Tool.DynamicDescription = (agent) =>
Effect.gen(function* () {
const items = yield* Effect.promise(() =>
Agent.list().then((items) => items.filter((item) => item.mode !== "primary")),
)
const filtered = items.filter((item) => Permission.evaluate(id, 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")
})

View File

@@ -171,7 +171,7 @@ export namespace Worktree {
export const layer: Layer.Layer< export const layer: Layer.Layer<
Service, Service,
never, never,
AppFileSystem.Service | Path.Path | ChildProcessSpawner.ChildProcessSpawner | Project.Service AppFileSystem.Service | Path.Path | ChildProcessSpawner.ChildProcessSpawner | Git.Service | Project.Service
> = Layer.effect( > = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
@@ -179,6 +179,7 @@ export namespace Worktree {
const fs = yield* AppFileSystem.Service const fs = yield* AppFileSystem.Service
const pathSvc = yield* Path.Path const pathSvc = yield* Path.Path
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
const gitSvc = yield* Git.Service
const project = yield* Project.Service const project = yield* Project.Service
const git = Effect.fnUntraced( const git = Effect.fnUntraced(
@@ -516,7 +517,7 @@ export namespace Worktree {
const worktreePath = entry.path const worktreePath = entry.path
const base = yield* Effect.promise(() => Git.defaultBranch(Instance.worktree)) const base = yield* gitSvc.defaultBranch(Instance.worktree)
if (!base) { if (!base) {
throw new ResetFailedError({ message: "Default branch not found" }) throw new ResetFailedError({ message: "Default branch not found" })
} }
@@ -583,6 +584,7 @@ export namespace Worktree {
) )
const defaultLayer = layer.pipe( const defaultLayer = layer.pipe(
Layer.provide(Git.defaultLayer),
Layer.provide(CrossSpawnSpawner.defaultLayer), Layer.provide(CrossSpawnSpawner.defaultLayer),
Layer.provide(Project.defaultLayer), Layer.provide(Project.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer), Layer.provide(AppFileSystem.defaultLayer),

View File

@@ -28,6 +28,7 @@ import { SessionPrompt } from "../../src/session/prompt"
import { SessionRunState } from "../../src/session/run-state" import { SessionRunState } from "../../src/session/run-state"
import { MessageID, PartID, SessionID } from "../../src/session/schema" import { MessageID, PartID, SessionID } from "../../src/session/schema"
import { SessionStatus } from "../../src/session/status" import { SessionStatus } from "../../src/session/status"
import { Skill } from "../../src/skill"
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/registry"
@@ -166,6 +167,7 @@ function makeHttp() {
const question = Question.layer.pipe(Layer.provideMerge(deps)) const question = Question.layer.pipe(Layer.provideMerge(deps))
const todo = Todo.layer.pipe(Layer.provideMerge(deps)) const todo = Todo.layer.pipe(Layer.provideMerge(deps))
const registry = ToolRegistry.layer.pipe( const registry = ToolRegistry.layer.pipe(
Layer.provide(Skill.defaultLayer),
Layer.provideMerge(todo), Layer.provideMerge(todo),
Layer.provideMerge(question), Layer.provideMerge(question),
Layer.provideMerge(deps), Layer.provideMerge(deps),

View File

@@ -39,6 +39,7 @@ import { Permission } from "../../src/permission"
import { Plugin } from "../../src/plugin" import { Plugin } from "../../src/plugin"
import { Provider as ProviderSvc } from "../../src/provider/provider" import { Provider as ProviderSvc } from "../../src/provider/provider"
import { Question } from "../../src/question" import { Question } from "../../src/question"
import { Skill } from "../../src/skill"
import { Todo } from "../../src/session/todo" import { Todo } from "../../src/session/todo"
import { SessionCompaction } from "../../src/session/compaction" import { SessionCompaction } from "../../src/session/compaction"
import { Instruction } from "../../src/session/instruction" import { Instruction } from "../../src/session/instruction"
@@ -131,6 +132,7 @@ function makeHttp() {
const question = Question.layer.pipe(Layer.provideMerge(deps)) const question = Question.layer.pipe(Layer.provideMerge(deps))
const todo = Todo.layer.pipe(Layer.provideMerge(deps)) const todo = Todo.layer.pipe(Layer.provideMerge(deps))
const registry = ToolRegistry.layer.pipe( const registry = ToolRegistry.layer.pipe(
Layer.provide(Skill.defaultLayer),
Layer.provideMerge(todo), Layer.provideMerge(todo),
Layer.provideMerge(question), Layer.provideMerge(question),
Layer.provideMerge(deps), Layer.provideMerge(deps),

View File

@@ -5,7 +5,8 @@ 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/tool"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { SkillTool, SkillDescription } from "../../src/tool/skill" import { SkillTool } from "../../src/tool/skill"
import { ToolRegistry } from "../../src/tool/registry"
import { tmpdir } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture"
import { SessionID, MessageID } from "../../src/session/schema" import { SessionID, MessageID } from "../../src/session/schema"
@@ -49,9 +50,11 @@ description: Skill for tool tests.
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const desc = await Effect.runPromise( const desc = await ToolRegistry.tools({
SkillDescription({ name: "build", mode: "primary" as const, permission: [], options: {} }), providerID: "opencode" as any,
) modelID: "gpt-5" as any,
agent: { name: "build", mode: "primary" as const, permission: [], options: {} },
}).then((tools) => tools.find((tool) => tool.id === SkillTool.id)?.description ?? "")
expect(desc).toContain(`**tool-skill**: Skill for tool tests.`) expect(desc).toContain(`**tool-skill**: Skill for tool tests.`)
}, },
}) })
@@ -92,8 +95,14 @@ description: ${description}
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const agent = { name: "build", mode: "primary" as const, permission: [], options: {} } const agent = { name: "build", mode: "primary" as const, permission: [], options: {} }
const first = await Effect.runPromise(SkillDescription(agent)) const load = () =>
const second = await Effect.runPromise(SkillDescription(agent)) ToolRegistry.tools({
providerID: "opencode" as any,
modelID: "gpt-5" as any,
agent,
}).then((tools) => tools.find((tool) => tool.id === SkillTool.id)?.description ?? "")
const first = await load()
const second = await load()
expect(first).toBe(second) expect(first).toBe(second)

View File

@@ -9,7 +9,8 @@ import { MessageV2 } from "../../src/session/message-v2"
import { SessionPrompt } from "../../src/session/prompt" import { 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 { TaskDescription, TaskTool } from "../../src/tool/task" import { TaskTool } from "../../src/tool/task"
import { ToolRegistry } from "../../src/tool/registry"
import { provideTmpdirInstance } from "../fixture/fixture" import { provideTmpdirInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect" import { testEffect } from "../lib/effect"
@@ -23,7 +24,13 @@ const ref = {
} }
const it = testEffect( const it = testEffect(
Layer.mergeAll(Agent.defaultLayer, Config.defaultLayer, CrossSpawnSpawner.defaultLayer, Session.defaultLayer), Layer.mergeAll(
Agent.defaultLayer,
Config.defaultLayer,
CrossSpawnSpawner.defaultLayer,
Session.defaultLayer,
ToolRegistry.defaultLayer,
),
) )
const seed = Effect.fn("TaskToolTest.seed")(function* (title = "Pinned") { const seed = Effect.fn("TaskToolTest.seed")(function* (title = "Pinned") {
@@ -92,8 +99,13 @@ describe("tool.task", () => {
Effect.gen(function* () { Effect.gen(function* () {
const agent = yield* Agent.Service const agent = yield* Agent.Service
const build = yield* agent.get("build") const build = yield* agent.get("build")
const first = yield* TaskDescription(build) const registry = yield* ToolRegistry.Service
const second = yield* TaskDescription(build) const get = Effect.fnUntraced(function* () {
const tools = yield* registry.tools({ ...ref, agent: build })
return tools.find((tool) => tool.id === TaskTool.id)?.description ?? ""
})
const first = yield* get()
const second = yield* get()
expect(first).toBe(second) expect(first).toBe(second)
@@ -130,7 +142,9 @@ describe("tool.task", () => {
Effect.gen(function* () { Effect.gen(function* () {
const agent = yield* Agent.Service const agent = yield* Agent.Service
const build = yield* agent.get("build") const build = yield* agent.get("build")
const description = yield* TaskDescription(build) const registry = yield* ToolRegistry.Service
const description =
(yield* registry.tools({ ...ref, agent: build })).find((tool) => tool.id === TaskTool.id)?.description ?? ""
expect(description).toContain("- alpha: Alpha agent") expect(description).toContain("- alpha: Alpha agent")
expect(description).not.toContain("- zebra: Zebra agent") expect(description).not.toContain("- zebra: Zebra agent")