convert skill tool to Tool.defineEffect (#21936)

This commit is contained in:
Kit Langton 2026-04-10 19:49:53 -04:00 committed by GitHub
commit 5d6fe01465
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 90 additions and 83 deletions

View file

@ -116,6 +116,7 @@ export namespace ToolRegistry {
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 state = yield* InstanceState.make<State>( const state = yield* InstanceState.make<State>(
Effect.fn("ToolRegistry.state")(function* (ctx) { Effect.fn("ToolRegistry.state")(function* (ctx) {
@ -185,7 +186,7 @@ export namespace ToolRegistry {
todo: Tool.init(todo), todo: Tool.init(todo),
search: Tool.init(websearch), search: Tool.init(websearch),
code: Tool.init(codesearch), code: Tool.init(codesearch),
skill: Tool.init(SkillTool), skill: Tool.init(skilltool),
patch: Tool.init(patchtool), patch: Tool.init(patchtool),
question: Tool.init(question), question: Tool.init(question),
lsp: Tool.init(lsptool), lsp: Tool.init(lsptool),

View file

@ -1,99 +1,101 @@
import path from "path" import path from "path"
import { pathToFileURL } from "url" import { pathToFileURL } from "url"
import z from "zod" import z from "zod"
import { Effect } from "effect"
import * as Stream from "effect/Stream"
import { Tool } from "./tool" import { Tool } from "./tool"
import { Skill } from "../skill" import { Skill } from "../skill"
import { Ripgrep } from "../file/ripgrep" import { Ripgrep } from "../file/ripgrep"
import { iife } from "@/util/iife"
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"),
}) })
export const SkillTool = Tool.define("skill", async () => { export const SkillTool = Tool.defineEffect(
const list = await Skill.available() "skill",
Effect.gen(function* () {
const skill = yield* Skill.Service
const rg = yield* Ripgrep.Service
const description = return async () => {
list.length === 0 const list = await Effect.runPromise(skill.available())
? "Load a specialized skill that provides domain-specific instructions and workflows. No skills are currently available."
: [
"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")
return { const description =
description, list.length === 0
parameters: Parameters, ? "Load a specialized skill that provides domain-specific instructions and workflows. No skills are currently available."
async execute(params: z.infer<typeof Parameters>, ctx) { : [
const skill = await Skill.get(params.name) "Load a specialized skill that provides domain-specific instructions and workflows.",
"",
if (!skill) { "When you recognize that a task matches one of the available skills listed below, use this tool to load the full skill instructions.",
const available = await Skill.all().then((x) => x.map((skill) => skill.name).join(", ")) "",
throw new Error(`Skill "${params.name}" not found. Available skills: ${available || "none"}`) "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.',
await ctx.ask({ "",
permission: "skill", "The following skills provide specialized sets of instructions for particular tasks",
patterns: [params.name], "Invoke this tool to load a skill when a task matches one of the available skills listed below:",
always: [params.name], "",
metadata: {}, Skill.fmt(list, { verbose: false }),
}) ].join("\n")
const dir = path.dirname(skill.location)
const base = pathToFileURL(dir).href
const limit = 10
const files = await iife(async () => {
const arr = []
for await (const file of Ripgrep.files({
cwd: dir,
follow: false,
hidden: true,
signal: ctx.abort,
})) {
if (file.includes("SKILL.md")) {
continue
}
arr.push(path.resolve(dir, file))
if (arr.length >= limit) {
break
}
}
return arr
}).then((f) => f.map((file) => `<file>${file}</file>`).join("\n"))
return { return {
title: `Loaded skill: ${skill.name}`, description,
output: [ parameters: Parameters,
`<skill_content name="${skill.name}">`, execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) =>
`# Skill: ${skill.name}`, Effect.gen(function* () {
"", const info = yield* skill.get(params.name)
skill.content.trim(),
"", if (!info) {
`Base directory for this skill: ${base}`, const all = yield* skill.all()
"Relative paths in this skill (e.g., scripts/, reference/) are relative to this base directory.", const available = all.map((s) => s.name).join(", ")
"Note: file list is sampled.", throw new Error(`Skill "${params.name}" not found. Available skills: ${available || "none"}`)
"", }
"<skill_files>",
files, yield* Effect.promise(() =>
"</skill_files>", ctx.ask({
"</skill_content>", permission: "skill",
].join("\n"), patterns: [params.name],
metadata: { always: [params.name],
name: skill.name, metadata: {},
dir, }),
}, )
const dir = path.dirname(info.location)
const base = pathToFileURL(dir).href
const limit = 10
const files = yield* rg.files({ cwd: dir, follow: false, hidden: true }).pipe(
Stream.filter((file) => !file.includes("SKILL.md")),
Stream.map((file) => path.resolve(dir, file)),
Stream.take(limit),
Stream.runCollect,
Effect.map((chunk) => [...chunk].map((file) => `<file>${file}</file>`).join("\n")),
)
return {
title: `Loaded skill: ${info.name}`,
output: [
`<skill_content name="${info.name}">`,
`# Skill: ${info.name}`,
"",
info.content.trim(),
"",
`Base directory for this skill: ${base}`,
"Relative paths in this skill (e.g., scripts/, reference/) are relative to this base directory.",
"Note: file list is sampled.",
"",
"<skill_files>",
files,
"</skill_files>",
"</skill_content>",
].join("\n"),
metadata: {
name: info.name,
dir,
},
}
}).pipe(Effect.orDie, Effect.runPromise),
} }
}, }
} }),
}) )

View file

@ -1,4 +1,6 @@
import { Effect } from "effect" import { Effect, Layer, ManagedRuntime } from "effect"
import { Skill } from "../../src/skill"
import { Ripgrep } from "../../src/file/ripgrep"
import { afterEach, describe, expect, test } from "bun:test" import { afterEach, describe, expect, test } from "bun:test"
import path from "path" import path from "path"
import { pathToFileURL } from "url" import { pathToFileURL } from "url"
@ -148,7 +150,9 @@ Use this skill.
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const tool = await SkillTool.init() const runtime = ManagedRuntime.make(Layer.mergeAll(Skill.defaultLayer, Ripgrep.defaultLayer))
const info = await runtime.runPromise(SkillTool)
const tool = await info.init()
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const ctx: Tool.Context = { const ctx: Tool.Context = {
...baseCtx, ...baseCtx,