diff options
Diffstat (limited to 'panto-agent/skill.lua')
| -rw-r--r-- | panto-agent/skill.lua | 194 |
1 files changed, 194 insertions, 0 deletions
diff --git a/panto-agent/skill.lua b/panto-agent/skill.lua new file mode 100644 index 0000000..e26f8ac --- /dev/null +++ b/panto-agent/skill.lua @@ -0,0 +1,194 @@ +-- agent.skill: on-demand Agent Skills (agentskills.io) via an +-- `agent.skill` tool. +-- +-- Discovery happens once, at activation. Skill directories are searched +-- lowest priority first: +-- +-- base: <XDG_DATA_HOME>/panto/agent/skills +-- user: <XDG_CONFIG_HOME>/panto/skills +-- ~/.agent/skills +-- project: <cwd>/.panto/skills +-- <cwd>/.agent/skills +-- +-- The `.agent/skills` variants outrank their own layer's normal directory +-- but nothing above it (`~/.agent/skills` does not outrank +-- `<cwd>/.panto/skills`). A skill is any subdirectory containing a +-- SKILL.md with YAML frontmatter; the frontmatter `name` (not the +-- directory name) is the skill's identity, and a later directory's skill +-- shadows an earlier one with the same name. +-- +-- The surviving names and descriptions are baked into one registered +-- tool: the description lists every skill, and the input schema +-- constrains `skill` to the discovered names as an enum. Calling the tool +-- re-reads the skill's SKILL.md (so edits mid-session are picked up), +-- strips the frontmatter, and returns the instructions behind a short +-- preamble telling the model to resolve the skill's relative paths +-- (scripts/, references/, assets/) against the skill's real directory. +-- +-- Frontmatter parsing is deliberately minimal: top-level `key: value` +-- lines only (quoted values unquoted). Nested mappings like `metadata:` +-- are ignored, which is all this extension needs (`name`, `description`). + +local uv = require("luv") +local panto = require("panto") + +local M = { name = "agent.skill", skills = {} } + +-- Coroutine-aware libuv await (same shape as agent.rules): arm the +-- callback form of a uv request, yield, resume with its results. Panto +-- runs every user-Lua entrypoint (activate, event handlers, tools) +-- inside a coroutine with the uv loop driven, so this just works. +local function await(label, arm) + local co = assert(coroutine.running(), label .. ": await must run inside a coroutine") + local res + arm(function(...) + res = table.pack(...) + local ok, err = coroutine.resume(co) + if not ok then error(err, 0) end + end) + coroutine.yield() + return table.unpack(res, 1, res.n) +end + +local function fs_realpath(p) return await("fs_realpath", function(r) uv.fs_realpath(p, r) end) end +local function fs_open(p, flags, mode) return await("fs_open", function(r) uv.fs_open(p, flags, mode, r) end) end +local function fs_fstat(fd) return await("fs_fstat", function(r) uv.fs_fstat(fd, r) end) end +local function fs_read(fd, size, off) return await("fs_read", function(r) uv.fs_read(fd, size, off, r) end) end +local function fs_close(fd) return await("fs_close", function(r) uv.fs_close(fd, r) end) end +local function fs_scandir(p) return await("fs_scandir", function(r) uv.fs_scandir(p, r) end) end + +--- Read a whole file (nil on any failure). +local function read_all(path) + local oerr, fd = fs_open(path, "r", 0) + if not fd then return nil, oerr end + local serr, stat = fs_fstat(fd) + local rerr, text + if stat then + rerr, text = fs_read(fd, stat.size, 0) + else + rerr = serr + end + fs_close(fd) + if not text then return nil, rerr end + return text +end + +--- Entry names of a directory ({} when it doesn't exist / isn't readable). +local function list_dir(dir) + local _, req = fs_scandir(dir) + if not req then return {} end + local names = {} + while true do + local name = uv.fs_scandir_next(req) + if not name then break end + names[#names + 1] = name + end + return names +end + +--- Split SKILL.md into (meta, body). Returns nil unless the file opens +--- with a `---` frontmatter block containing a non-empty `name`. +function M.parse_skill_md(text) + local fm, body = text:match("^%-%-%-\r?\n(.-)\r?\n%-%-%-%s*\r?\n?(.*)$") + if not fm then return nil end + local meta = {} + for line in fm:gmatch("[^\r\n]+") do + local k, v = line:match("^([%w%-]+):%s*(.-)%s*$") + if k then + meta[k] = v:match('^"(.*)"$') or v:match("^'(.*)'$") or v + end + end + if not meta.name or meta.name == "" then return nil end + return meta, body +end + +--- The skill directories to search, lowest priority first. `cwd` is the +--- project root (falls back to the process cwd). +function M.skill_dirs(cwd) + local dirs = {} + local home = os.getenv("HOME") + local data = os.getenv("XDG_DATA_HOME") or (home and home .. "/.local/share") + if data then dirs[#dirs + 1] = data .. "/panto/agent/skills" end + local cfg = os.getenv("XDG_CONFIG_HOME") or (home and home .. "/.config") + if cfg then dirs[#dirs + 1] = cfg .. "/panto/skills" end + if home then dirs[#dirs + 1] = home .. "/.agent/skills" end + local root = cwd or uv.cwd() + dirs[#dirs + 1] = root .. "/.panto/skills" + dirs[#dirs + 1] = root .. "/.agent/skills" + return dirs +end + +--- Scan `dirs` (lowest priority first) and return a map of skill name -> +--- { dir = <resolved skill directory>, description }. Same-named skills +--- from later directories shadow earlier ones. Awaits, so it must run +--- inside a panto entrypoint (or an equivalent harness). +function M.discover(dirs) + local skills = {} + for _, dir in ipairs(dirs) do + for _, entry in ipairs(list_dir(dir)) do + local _, real = fs_realpath(dir .. "/" .. entry) + if real then + local text = read_all(real .. "/SKILL.md") + local meta = text and M.parse_skill_md(text) + if meta then + skills[meta.name] = { dir = real, description = meta.description or "" } + end + end + end + end + return skills +end + +--- Tool handler: return the named skill's SKILL.md instructions +--- (frontmatter stripped) behind a path-resolution preamble. Unknown +--- names come back as a plain-text error the model can react to. +function M.on_call(input) + local s = type(input) == "table" and type(input.skill) == "string" and M.skills[input.skill] + if not s then + local names = {} + for name in pairs(M.skills) do names[#names + 1] = name end + table.sort(names) + return "unknown skill: " .. tostring(type(input) == "table" and input.skill or input) + .. ". Available skills: " .. table.concat(names, ", ") + end + local text = read_all(s.dir .. "/SKILL.md") + if not text then return "failed to read " .. s.dir .. "/SKILL.md" end + local meta, body = M.parse_skill_md(text) + if not meta then body = text end + return "Skill '" .. input.skill .. "', loaded from " .. s.dir .. "/SKILL.md.\n" + .. "Any relative path these instructions reference (scripts/, references/, assets/, ...)" + .. " lives under that directory: resolve it against " .. s.dir .. "/.\n\n" + .. body +end + +function M.activate() + M.skills = M.discover(M.skill_dirs(uv.cwd())) + local names = {} + for name in pairs(M.skills) do names[#names + 1] = name end + if #names == 0 then return end -- nothing to offer: don't register the tool + table.sort(names) + local lines = {} + for _, n in ipairs(names) do + lines[#lines + 1] = "- " .. n .. ": " .. M.skills[n].description + end + panto.ext.register_tool { + name = "agent.skill", + description = "Load a skill: expert instructions for a specific kind of task." + .. " Call this before starting work a skill covers.\nAvailable skills:\n" + .. table.concat(lines, "\n"), + schema = { + type = "object", + properties = { + skill = { + type = "string", + enum = names, + description = "Name of the skill to load.", + }, + }, + required = { "skill" }, + }, + handler = M.on_call, + } +end + +return M |
