mirror of
https://github.com/anomalyco/opencode.git
synced 2026-05-06 08:21:50 +00:00
refactor(effect): move tool descriptions into registry (#21795)
This commit is contained in:
parent
16c60c9ee7
commit
17bd16667c
8 changed files with 87 additions and 54 deletions
|
|
@ -5,12 +5,12 @@ import { EditTool } from "./edit"
|
|||
import { GlobTool } from "./glob"
|
||||
import { GrepTool } from "./grep"
|
||||
import { ReadTool } from "./read"
|
||||
import { TaskDescription, TaskTool } from "./task"
|
||||
import { TaskTool } from "./task"
|
||||
import { TodoWriteTool } from "./todo"
|
||||
import { WebFetchTool } from "./webfetch"
|
||||
import { WriteTool } from "./write"
|
||||
import { InvalidTool } from "./invalid"
|
||||
import { SkillDescription, SkillTool } from "./skill"
|
||||
import { SkillTool } from "./skill"
|
||||
import { Tool } from "./tool"
|
||||
import { Config } from "../config/config"
|
||||
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 { AppFileSystem } from "../filesystem"
|
||||
import { Agent } from "../agent/agent"
|
||||
import { Skill } from "../skill"
|
||||
import { Permission } from "@/permission"
|
||||
|
||||
export namespace ToolRegistry {
|
||||
const log = Log.create({ service: "tool.registry" })
|
||||
|
|
@ -73,6 +75,7 @@ export namespace ToolRegistry {
|
|||
| Question.Service
|
||||
| Todo.Service
|
||||
| Agent.Service
|
||||
| Skill.Service
|
||||
| LSP.Service
|
||||
| FileTime.Service
|
||||
| Instruction.Service
|
||||
|
|
@ -82,6 +85,8 @@ export namespace ToolRegistry {
|
|||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const plugin = yield* Plugin.Service
|
||||
const agents = yield* Agent.Service
|
||||
const skill = yield* Skill.Service
|
||||
|
||||
const task = yield* TaskTool
|
||||
const read = yield* ReadTool
|
||||
|
|
@ -199,6 +204,40 @@ export namespace ToolRegistry {
|
|||
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) {
|
||||
|
|
@ -227,8 +266,8 @@ export namespace ToolRegistry {
|
|||
id: tool.id,
|
||||
description: [
|
||||
output.description,
|
||||
tool.id === TaskTool.id ? yield* TaskDescription(input.agent) : undefined,
|
||||
tool.id === SkillTool.id ? yield* SkillDescription(input.agent) : undefined,
|
||||
tool.id === TaskTool.id ? yield* describeTask(input.agent) : undefined,
|
||||
tool.id === SkillTool.id ? yield* describeSkill(input.agent) : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n"),
|
||||
|
|
@ -257,7 +296,9 @@ export namespace ToolRegistry {
|
|||
Layer.provide(Plugin.defaultLayer),
|
||||
Layer.provide(Question.defaultLayer),
|
||||
Layer.provide(Todo.defaultLayer),
|
||||
Layer.provide(Skill.defaultLayer),
|
||||
Layer.provide(Agent.defaultLayer),
|
||||
Layer.provide(Skill.defaultLayer),
|
||||
Layer.provide(LSP.defaultLayer),
|
||||
Layer.provide(FileTime.defaultLayer),
|
||||
Layer.provide(Instruction.defaultLayer),
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import { Effect } from "effect"
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
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")
|
||||
})
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import { MessageV2 } from "../session/message-v2"
|
|||
import { Agent } from "../agent/agent"
|
||||
import { SessionPrompt } from "../session/prompt"
|
||||
import { Config } from "../config/config"
|
||||
import { Permission } from "@/permission"
|
||||
import { Effect } from "effect"
|
||||
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")
|
||||
})
|
||||
|
|
|
|||
|
|
@ -171,7 +171,7 @@ export namespace Worktree {
|
|||
export const layer: Layer.Layer<
|
||||
Service,
|
||||
never,
|
||||
AppFileSystem.Service | Path.Path | ChildProcessSpawner.ChildProcessSpawner | Project.Service
|
||||
AppFileSystem.Service | Path.Path | ChildProcessSpawner.ChildProcessSpawner | Git.Service | Project.Service
|
||||
> = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
|
|
@ -179,6 +179,7 @@ export namespace Worktree {
|
|||
const fs = yield* AppFileSystem.Service
|
||||
const pathSvc = yield* Path.Path
|
||||
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
||||
const gitSvc = yield* Git.Service
|
||||
const project = yield* Project.Service
|
||||
|
||||
const git = Effect.fnUntraced(
|
||||
|
|
@ -516,7 +517,7 @@ export namespace Worktree {
|
|||
|
||||
const worktreePath = entry.path
|
||||
|
||||
const base = yield* Effect.promise(() => Git.defaultBranch(Instance.worktree))
|
||||
const base = yield* gitSvc.defaultBranch(Instance.worktree)
|
||||
if (!base) {
|
||||
throw new ResetFailedError({ message: "Default branch not found" })
|
||||
}
|
||||
|
|
@ -583,6 +584,7 @@ export namespace Worktree {
|
|||
)
|
||||
|
||||
const defaultLayer = layer.pipe(
|
||||
Layer.provide(Git.defaultLayer),
|
||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||
Layer.provide(Project.defaultLayer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import { SessionPrompt } from "../../src/session/prompt"
|
|||
import { SessionRunState } from "../../src/session/run-state"
|
||||
import { MessageID, PartID, SessionID } from "../../src/session/schema"
|
||||
import { SessionStatus } from "../../src/session/status"
|
||||
import { Skill } from "../../src/skill"
|
||||
import { Shell } from "../../src/shell/shell"
|
||||
import { Snapshot } from "../../src/snapshot"
|
||||
import { ToolRegistry } from "../../src/tool/registry"
|
||||
|
|
@ -166,6 +167,7 @@ function makeHttp() {
|
|||
const question = Question.layer.pipe(Layer.provideMerge(deps))
|
||||
const todo = Todo.layer.pipe(Layer.provideMerge(deps))
|
||||
const registry = ToolRegistry.layer.pipe(
|
||||
Layer.provide(Skill.defaultLayer),
|
||||
Layer.provideMerge(todo),
|
||||
Layer.provideMerge(question),
|
||||
Layer.provideMerge(deps),
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ import { Permission } from "../../src/permission"
|
|||
import { Plugin } from "../../src/plugin"
|
||||
import { Provider as ProviderSvc } from "../../src/provider/provider"
|
||||
import { Question } from "../../src/question"
|
||||
import { Skill } from "../../src/skill"
|
||||
import { Todo } from "../../src/session/todo"
|
||||
import { SessionCompaction } from "../../src/session/compaction"
|
||||
import { Instruction } from "../../src/session/instruction"
|
||||
|
|
@ -131,6 +132,7 @@ function makeHttp() {
|
|||
const question = Question.layer.pipe(Layer.provideMerge(deps))
|
||||
const todo = Todo.layer.pipe(Layer.provideMerge(deps))
|
||||
const registry = ToolRegistry.layer.pipe(
|
||||
Layer.provide(Skill.defaultLayer),
|
||||
Layer.provideMerge(todo),
|
||||
Layer.provideMerge(question),
|
||||
Layer.provideMerge(deps),
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@ import { pathToFileURL } from "url"
|
|||
import type { Permission } from "../../src/permission"
|
||||
import type { Tool } from "../../src/tool/tool"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { SkillTool, SkillDescription } from "../../src/tool/skill"
|
||||
import { SkillTool } from "../../src/tool/skill"
|
||||
import { ToolRegistry } from "../../src/tool/registry"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import { SessionID, MessageID } from "../../src/session/schema"
|
||||
|
||||
|
|
@ -49,9 +50,11 @@ description: Skill for tool tests.
|
|||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const desc = await Effect.runPromise(
|
||||
SkillDescription({ name: "build", mode: "primary" as const, permission: [], options: {} }),
|
||||
)
|
||||
const desc = await ToolRegistry.tools({
|
||||
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.`)
|
||||
},
|
||||
})
|
||||
|
|
@ -92,8 +95,14 @@ description: ${description}
|
|||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const agent = { name: "build", mode: "primary" as const, permission: [], options: {} }
|
||||
const first = await Effect.runPromise(SkillDescription(agent))
|
||||
const second = await Effect.runPromise(SkillDescription(agent))
|
||||
const load = () =>
|
||||
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)
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,8 @@ import { MessageV2 } from "../../src/session/message-v2"
|
|||
import { SessionPrompt } from "../../src/session/prompt"
|
||||
import { MessageID, PartID } from "../../src/session/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 { testEffect } from "../lib/effect"
|
||||
|
||||
|
|
@ -23,7 +24,13 @@ const ref = {
|
|||
}
|
||||
|
||||
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") {
|
||||
|
|
@ -92,8 +99,13 @@ describe("tool.task", () => {
|
|||
Effect.gen(function* () {
|
||||
const agent = yield* Agent.Service
|
||||
const build = yield* agent.get("build")
|
||||
const first = yield* TaskDescription(build)
|
||||
const second = yield* TaskDescription(build)
|
||||
const registry = yield* ToolRegistry.Service
|
||||
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)
|
||||
|
||||
|
|
@ -130,7 +142,9 @@ describe("tool.task", () => {
|
|||
Effect.gen(function* () {
|
||||
const agent = yield* Agent.Service
|
||||
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).not.toContain("- zebra: Zebra agent")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue