ソースを参照

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

Kit Langton 1 週間 前
コミット
17bd16667c

+ 45 - 4
packages/opencode/src/tool/registry.ts

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

+ 0 - 21
packages/opencode/src/tool/skill.ts

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

+ 0 - 16
packages/opencode/src/tool/task.ts

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

+ 4 - 2
packages/opencode/src/worktree/index.ts

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

+ 2 - 0
packages/opencode/test/session/prompt-effect.test.ts

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

+ 2 - 0
packages/opencode/test/session/snapshot-tool-race.test.ts

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

+ 15 - 6
packages/opencode/test/tool/skill.test.ts

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

+ 19 - 5
packages/opencode/test/tool/task.test.ts

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