From 94568d9f70b12fa348040a7998dac7b0808b0258 Mon Sep 17 00:00:00 2001 From: t Date: Fri, 3 Jul 2026 09:49:13 -0600 Subject: Initial changes: agent.rules and agent.skill extensions panto-agent is a luarocks rock providing the agent.* extension suite for pantograph's panto CLI: - agent.rules: injects AGENTS.md/CLAUDE.md (AGENTS.md wins, never both) from the base agent dir, user config dir, and project root into the system prompt at session start. Also hooks std.read tool results to append rules files found between a read location and the project root, each injected at most once per session. - agent.skill: discovers Agent Skills (SKILL.md with YAML frontmatter) across data/config/home/project skill dirs with later dirs shadowing earlier ones, and exposes them via an agent.skill tool whose enum and description are built from the surviving skills. The tool returns the skill's instructions with frontmatter stripped. Not registered when no skills are found. Includes a rockspec, README, and a panto-runnable self-check (_selfcheck.lua, underscore-prefixed so panto's directory scan skips it). Sources live under panto-agent/ so require works the same installed or from a checkout. --- README.md | 69 +++++++++++++ _selfcheck.lua | 236 +++++++++++++++++++++++++++++++++++++++++++++ panto-agent-scm-1.rockspec | 31 ++++++ panto-agent/init.lua | 20 ++++ panto-agent/rules.lua | 215 +++++++++++++++++++++++++++++++++++++++++ panto-agent/skill.lua | 194 +++++++++++++++++++++++++++++++++++++ 6 files changed, 765 insertions(+) create mode 100644 README.md create mode 100644 _selfcheck.lua create mode 100644 panto-agent-scm-1.rockspec create mode 100644 panto-agent/init.lua create mode 100644 panto-agent/rules.lua create mode 100644 panto-agent/skill.lua diff --git a/README.md b/README.md new file mode 100644 index 0000000..3bd4b57 --- /dev/null +++ b/README.md @@ -0,0 +1,69 @@ +# panto-agent + +The `agent.*` extension suite for [pantograph](https://github.com/travisp/pantograph)'s +`panto` CLI: coding-agent conveniences packaged as a luarocks rock. + +## Extensions + +- **`agent.rules`** — at session start, injects the contents of `AGENTS.md` + (or `CLAUDE.md` when no `AGENTS.md` exists — never both) into the system + prompt, from each of: the base layer agent dir + (`${XDG_DATA_HOME:-~/.local/share}/panto/agent`), the user layer dir + (`${XDG_CONFIG_HOME:-~/.config}/panto`), and the project root (cwd). + Symlinks are resolved; each file becomes one system message headed + `context from :`. + + It also hooks `std.read` results (a `tool_result` event handler using + the writable `output` field): reading a file (or directory) below the + project root appends any `AGENTS.md`/`CLAUDE.md` found between the read + location and the root (root excluded — the system prompt covers it) to + the tool result as `---`-separated `context from :` sections. + Each real file is injected at most once per session. The transcript + keeps the read's own output, with a `+ rules: ` line per attached + file below it. + +- **`agent.skill`** — an `agent.skill` tool for loading + [Agent Skills](https://agentskills.io) on demand. At activation it + discovers skills (subdirectories containing a `SKILL.md` with YAML + frontmatter) from, lowest priority first: + `${XDG_DATA_HOME:-~/.local/share}/panto/agent/skills`, + `${XDG_CONFIG_HOME:-~/.config}/panto/skills`, `~/.agent/skills`, + `/.panto/skills`, and `/.agent/skills`. The frontmatter + `name` (not the directory name) is the skill's identity; later + directories shadow same-named skills from earlier ones. Surviving + names and descriptions are baked into the tool's description and its + input schema's enum. Calling the tool returns the skill's `SKILL.md` + instructions (frontmatter stripped) behind a preamble that tells the + model to resolve the skill's relative paths against its directory. + With no skills discovered, the tool is not registered. + +## Install + +Via the rock (once published): + +```toml +[extensions] +rocks = ["panto-agent"] +``` + +Or as a local checkout: + +```toml +[extensions] +paths = ["/path/to/panto-agent"] +``` + +Individual extensions can be toggled with the usual policy globs, e.g. +`deny = ["agent.rules"]`. + +## Development + +Run the self-check (needs `panto` for its embedded Lua + luv): + +```sh +panto lua _selfcheck.lua +``` + +Layout note: the sources live under `panto-agent/` (matching the installed +module tree) so `require` works identically installed and from a checkout; +`_selfcheck.lua` is `_`-prefixed so panto's directory scan skips it. diff --git a/_selfcheck.lua b/_selfcheck.lua new file mode 100644 index 0000000..adf02f1 --- /dev/null +++ b/_selfcheck.lua @@ -0,0 +1,236 @@ +-- Self-check for the panto-agent suite. Run with: +-- +-- panto lua panto-agent/_selfcheck.lua +-- +-- Uses a stub `panto` module, so it exercises the pure extension logic +-- (file selection, symlink resolution, message format) without a session. + +local uv = require("luv") + +-- Stub panto module: capture agent:add_system_message calls and `on` +-- subscriptions (ext.agent stands in for the session agent). Must be +-- seeded before rules.lua is required. +local captured = {} +local handlers = {} +local registered = {} +package.loaded["panto"] = { + ext = { + agent = { + add_system_message = function(_, text) captured[#captured + 1] = text end, + }, + on = function(event, handler) handlers[event] = handler end, + register_tool = function(t) registered[#registered + 1] = t end, + json = { + -- Just enough JSON for the inputs this selfcheck fabricates + -- (a flat object with a "path" string, no escapes). + decode = function(s) return { path = s:match('"path":"(.-)"') } end, + }, + }, +} + +-- Make the suite requirable relative to this file, wherever cwd is. +local here = debug.getinfo(1, "S").source:match("^@(.*)/[^/]+$") or "." +package.path = here .. "/panto-agent/?.lua;" .. here .. "/panto-agent/?/init.lua;" .. package.path + +local rules = require("rules") +assert(rules.name == "agent.rules", "entry name") + +-- Harness pump: panto runs entrypoints inside a coroutine with the uv +-- loop driven; this standalone script has to provide the same +-- environment itself. +local function run(fn) + local results + local co = coroutine.create(function() results = table.pack(fn()) end) + local ok, err = coroutine.resume(co) + if not ok then error(err, 0) end + while coroutine.status(co) ~= "dead" do + uv.run("once") + end + return table.unpack(results, 1, results.n) +end + +local function write(path, text) + local f = assert(io.open(path, "w")) + f:write(text) + f:close() +end + +local tmp = assert(uv.fs_mkdtemp("/tmp/panto-agent-selfcheck-XXXXXX")) +local function dir(name) + local d = tmp .. "/" .. name + assert(uv.fs_mkdir(d, 493)) -- 0755 + return d +end + +-- both: AGENTS.md wins, CLAUDE.md ignored +local both = dir("both") +write(both .. "/AGENTS.md", "agents rules") +write(both .. "/CLAUDE.md", "claude rules") +-- claude-only: falls back to CLAUDE.md +local claude_only = dir("claude-only") +write(claude_only .. "/CLAUDE.md", "claude only") +-- empty: contributes nothing +local empty = dir("empty") +-- linked: AGENTS.md is a symlink; resolved path reported +local target_dir = dir("target") +write(target_dir .. "/real-rules.md", "linked rules") +local linked = dir("linked") +assert(uv.fs_symlink(target_dir .. "/real-rules.md", linked .. "/AGENTS.md")) + +-- activate(): subscribes to session_start; each firing resets the +-- session-global `seen` set and injects one message per file, using the +-- event's cwd as the project root. Activate first so firing session_start +-- doubles as the seen-reset between the scenarios below. +rules.layer_dirs = function(cwd) return { cwd } end +rules.activate() +local on_session_start = handlers["session_start"] +assert(type(on_session_start) == "function", "session_start handler registered") +assert(#captured == 0, "nothing injected before session_start fires") + +-- collect() awaits libuv callbacks, so it runs under the harness pump. +local got = run(function() return rules.collect({ both, claude_only, empty, linked }) end) +assert(#got == 3, "expected 3 collected, got " .. #got) +assert(got[1].path == uv.fs_realpath(both .. "/AGENTS.md"), "AGENTS.md over CLAUDE.md") +assert(got[1].text == "agents rules") +assert(got[2].path == uv.fs_realpath(claude_only .. "/CLAUDE.md"), "CLAUDE.md fallback") +assert(got[2].text == "claude only") +assert(got[3].path == uv.fs_realpath(target_dir .. "/real-rules.md"), "symlink resolved") +assert(got[3].text == "linked rules") + +-- Session-global dedupe: a repeat collect of already-seen files is empty. +local again = run(function() return rules.collect({ both }) end) +assert(#again == 0, "seen files are not collected twice in a session") + +-- A session boundary resets `seen` (empty dir: injects nothing). +run(function() on_session_start({ cwd = empty }) end) +assert(#captured == 0, "empty root injects nothing") +-- Two dirs reaching the same real file within one collect: used once. +local dedup = run(function() return rules.collect({ linked, target_dir }) end) +assert(#dedup == 1, "same real file deduped") + +run(function() on_session_start({ cwd = both }) end) +assert(#captured == 1, "one system message") +local want = "context from " .. uv.fs_realpath(both .. "/AGENTS.md") .. ":\n\nagents rules" +assert(captured[1] == want, "message format:\n" .. captured[1]) +-- A second session boundary (e.g. /new) re-injects for the new cwd. +run(function() on_session_start({ cwd = claude_only }) end) +assert(#captured == 2 and captured[2]:find("claude only"), "re-injects on next session") + +-- tool_result handler: rules files between a read and the session root +-- ride on the result via the writable `output` field, and the component +-- wrapper lists them. Tree: proj/ (root, covered by the system prompt), +-- proj/sub (AGENTS.md), proj/sub/deep (CLAUDE.md only). +local proj = dir("proj") +write(proj .. "/AGENTS.md", "root rules") +assert(uv.fs_mkdir(proj .. "/sub", 493)) +write(proj .. "/sub/AGENTS.md", "sub rules") +assert(uv.fs_mkdir(proj .. "/sub/deep", 493)) +write(proj .. "/sub/deep/CLAUDE.md", "deep rules") +write(proj .. "/sub/deep/file.txt", "hello") + +run(function() on_session_start({ cwd = proj }) end) +local on_tool_result = handlers["tool_result"] +assert(type(on_tool_result) == "function", "tool_result handler registered") + +-- Fabricate a `tool_result` event: a plain table stands in for the real +-- event userdata (assignment and method calls look the same). +local fake_comp = { render = function(_, _) return { "read line" } end } +local function read_event(path) + local ev = { + tool_name = "std.read", + input = '{"path":"' .. path .. '"}', + output = "FILE", + } + ev.get_component = function() return fake_comp end + ev.set_component = function(_, t) ev.component = t end + return ev +end + +local ev = read_event(proj .. "/sub/deep/file.txt") +run(function() on_tool_result(ev) end) +local sub_real = uv.fs_realpath(proj .. "/sub/AGENTS.md") +local deep_real = uv.fs_realpath(proj .. "/sub/deep/CLAUDE.md") +assert(ev.output == "FILE" + .. "\n\n---\ncontext from " .. sub_real .. ":\n\nsub rules" + .. "\n\n---\ncontext from " .. deep_real .. ":\n\ndeep rules", + "walk order + format:\n" .. ev.output) +-- The component wrapper passes the original lines through and lists the +-- attached files below them. +local lines = ev.component.render(ev.component, 80) +assert(lines[1] == "read line", "wrapper renders through the original") +assert(lines[2] == "+ rules: " .. sub_real and lines[3] == "+ rules: " .. deep_real, + "wrapper lists attached files") + +-- Same read again: everything already seen, result and component untouched. +local ev2 = read_event(proj .. "/sub/deep/file.txt") +run(function() on_tool_result(ev2) end) +assert(ev2.output == "FILE" and ev2.component == nil, "already-seen rules are not re-injected") + +-- Reading a directory directly starts the walk at that directory. +assert(uv.fs_mkdir(proj .. "/other", 493)) +write(proj .. "/other/AGENTS.md", "other rules") +local ev3 = read_event(proj .. "/other") +run(function() on_tool_result(ev3) end) +assert(ev3.output:find("other rules", 1, true), "directory read includes its own rules") + +-- Reads outside the session root add nothing; other tools are ignored. +local ev4 = read_event(both .. "/AGENTS.md") +run(function() on_tool_result(ev4) end) +assert(ev4.output == "FILE", "outside-root reads are untouched") +local ev5 = read_event(proj .. "/sub/deep/file.txt") +ev5.tool_name = "std.shell" +run(function() on_tool_result(ev5) end) +assert(ev5.output == "FILE", "non-read tools are ignored") + +-- agent.skill: layered discovery, frontmatter-name shadowing, enum/desc +-- registration, frontmatter-stripped load with a path preamble. +local skill = require("skill") +assert(skill.name == "agent.skill", "skill entry name") + +local function write_skill(layer, dirname, name, desc, body) + local d = layer .. "/" .. dirname + assert(uv.fs_mkdir(d, 493)) + write(d .. "/SKILL.md", "---\nname: " .. name .. "\ndescription: " .. desc + .. "\nmetadata:\n author: selfcheck\n---\n" .. body) + return d +end + +local sk_low = dir("skills-low") +local sk_high = dir("skills-high") +-- alpha in both layers; the high layer's directory name differs from the +-- frontmatter name, which is the identity that shadows. +write_skill(sk_low, "alpha", "alpha", "low alpha", "low alpha body\n") +local alpha_high = write_skill(sk_high, "alpha-dir", "alpha", '"high alpha"', "high alpha body\n") +write_skill(sk_low, "beta", "beta", "beta desc", "beta body\n") +-- Not skills: no SKILL.md; SKILL.md without frontmatter. +assert(uv.fs_mkdir(sk_low .. "/noskill", 493)) +assert(uv.fs_mkdir(sk_low .. "/badfm", 493)) +write(sk_low .. "/badfm/SKILL.md", "just markdown, no frontmatter\n") + +skill.skill_dirs = function() return { sk_low, sk_high } end +run(function() skill.activate() end) +assert(#registered == 1, "one tool registered") +local tool = registered[1] +assert(tool.name == "agent.skill", "tool name") +local enum = tool.schema.properties.skill.enum +assert(#enum == 2 and enum[1] == "alpha" and enum[2] == "beta", "enum is the surviving names, sorted") +assert(tool.description:find("- alpha: high alpha", 1, true), "shadowing layer's description wins") +assert(tool.description:find("- beta: beta desc", 1, true), "description lists every skill") + +local alpha_real = uv.fs_realpath(alpha_high) +local out = run(function() return tool.handler({ skill = "alpha" }) end) +assert(out:find("loaded from " .. alpha_real .. "/SKILL.md", 1, true), "preamble names the real path") +assert(out:find("resolve it against " .. alpha_real .. "/", 1, true), "preamble covers relative paths") +assert(out:find("high alpha body", 1, true), "higher layer shadows lower") +assert(not out:find("name: alpha", 1, true), "frontmatter stripped") + +local miss = run(function() return tool.handler({ skill = "nope" }) end) +assert(miss:find("unknown skill: nope", 1, true) and miss:find("alpha, beta", 1, true), + "unknown skill lists the available ones") + +-- No skills anywhere: the tool is not registered at all. +skill.skill_dirs = function() return { empty } end +run(function() skill.activate() end) +assert(#registered == 1, "no skills, no tool") + +print("panto-agent selfcheck: OK") diff --git a/panto-agent-scm-1.rockspec b/panto-agent-scm-1.rockspec new file mode 100644 index 0000000..bd3e459 --- /dev/null +++ b/panto-agent-scm-1.rockspec @@ -0,0 +1,31 @@ +rockspec_format = "3.0" +package = "panto-agent" +version = "scm-1" + +source = { + -- Placeholder until the suite moves to its own repository. + url = "git+https://example.invalid/panto-agent.git", +} + +description = { + summary = "The agent.* extension suite for pantograph", + detailed = [[ +Coding-agent extensions for the panto CLI: rules-file context injection +(agent.rules), with skills and subagents to follow. Load by adding +"panto-agent" to `extensions.rocks` in panto's config.toml. +]], + license = "MIT", +} + +dependencies = { + "lua >= 5.1", +} + +build = { + type = "builtin", + modules = { + ["panto-agent"] = "panto-agent/init.lua", + ["panto-agent.rules"] = "panto-agent/rules.lua", + ["panto-agent.skill"] = "panto-agent/skill.lua", + }, +} diff --git a/panto-agent/init.lua b/panto-agent/init.lua new file mode 100644 index 0000000..017f4ee --- /dev/null +++ b/panto-agent/init.lua @@ -0,0 +1,20 @@ +-- panto-agent: the agent.* extension suite for pantograph. +-- +-- Returns the list of extension entries this rock provides. Works both +-- installed as a rock (`require("panto-agent")`, submodules namespaced) +-- and as a directory source via `extensions.paths` (siblings on +-- `package.path`). + +local function sub(name) + -- Truncate to one value: require returns (module, loaderdata) and a + -- trailing multi-value would expand inside the entries list below. + local ok, mod = pcall(require, "panto-agent." .. name) + if ok then return mod end + local fallback = require(name) + return fallback +end + +return { + sub("rules"), + sub("skill"), +} diff --git a/panto-agent/rules.lua b/panto-agent/rules.lua new file mode 100644 index 0000000..77c234f --- /dev/null +++ b/panto-agent/rules.lua @@ -0,0 +1,215 @@ +-- agent.rules: inject AGENTS.md (or CLAUDE.md) context into the system +-- prompt at the start of every session, and subdirectory rules files into +-- `std.read` results as the agent explores. +-- +-- Activation subscribes to `session_start`, so the rules re-inject on any +-- future in-process session boundary (e.g. a `/new` command), not just at +-- startup. The handler looks in each config layer's directory — base +-- (/panto/agent), user (~/.config/panto), and the project root +-- (the session's cwd) — for an AGENTS.md, falling back to CLAUDE.md when +-- AGENTS.md is absent (never both). Each file found becomes one system +-- message: +-- +-- context from : +-- +-- +-- +-- Symlinks are resolved; the header shows the real path. The same real +-- file reachable from two layers is used once. +-- +-- A `tool_result` handler covers `std.read`: after each read, walk from +-- the read file's directory (or the directory itself, when one is read +-- directly) up to — but excluding — the session root, and append each +-- not-yet-seen rules file to the tool result via the writable event field +-- (`ev.output = ...`) as a plaintext section: +-- +-- --- +-- context from : +-- +-- +-- +-- Plaintext (not a protocol attachment) because panto attachments are +-- binary media (image/PDF) with no filename channel, and OpenAI-style +-- providers can't carry non-text in tool results at all. One session-wide +-- `seen` set (shared with the system-prompt pass) guarantees each real +-- file is injected at most once per session. +-- +-- The transcript keeps the read's original output; a component wrapper +-- (get_component → wrap → set_component) adds one "+ rules: " line +-- per attached file below it as a visual note. + +local uv = require("luv") +local panto = require("panto") + +local M = { name = "agent.rules" } + +local candidates = { "AGENTS.md", "CLAUDE.md" } + +-- Coroutine-aware libuv await, same shape as the std.* tools' _std.await +-- (not requirable from a rock): arm the callback form of a uv request, +-- yield, and let the callback resume us with its results. Every fs op +-- below routes through libuv's thread pool instead of blocking the shared +-- loop. Panto runs every user-Lua entrypoint (activate, event handlers, +-- commands, tools) inside a coroutine with the uv loop driven, so this +-- just works — no pumping on our side. +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 + +-- Awaited fs wrappers; all return (err, value) like the luv callbacks. +local function fs_realpath(p) return await("fs_realpath", function(r) uv.fs_realpath(p, r) end) end +local function fs_stat(p) return await("fs_stat", function(r) uv.fs_stat(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 + +-- Session state: the resolved session root and the set of rules files +-- (by real path) already injected this session — via the system prompt +-- or a read result. Reset at every session boundary. +local root = nil +local seen = {} + +--- The three layer directories to search, lowest first. `cwd` is the +--- project root (falls back to the process cwd). +function M.layer_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" end + local cfg = os.getenv("XDG_CONFIG_HOME") or (home and home .. "/.config") + if cfg then dirs[#dirs + 1] = cfg .. "/panto" end + dirs[#dirs + 1] = cwd or uv.cwd() + return dirs +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 + +--- Collect the rules files under `dirs`: a list of { path, text }, where +--- `path` is the symlink-resolved absolute path. AGENTS.md beats CLAUDE.md +--- within a directory; a real file already injected this session (the +--- module-global `seen`, reset at each session boundary) is skipped, and +--- newly collected files are marked. Awaits, so it must run inside a +--- panto entrypoint (or an equivalent harness). +function M.collect(dirs) + local out = {} + for _, dir in ipairs(dirs) do + for _, fname in ipairs(candidates) do + -- fs_realpath doubles as an existence check (err if missing + -- or a broken symlink) and resolves symlinks. + local _, real = fs_realpath(dir .. "/" .. fname) + if real then + if not seen[real] then + seen[real] = true + local text = read_all(real) + if text then + out[#out + 1] = { path = real, text = text } + end + end + break -- AGENTS.md present: never also use CLAUDE.md + end + end + end + return out +end + +local function dirname(p) + local d = p:match("^(.*)/[^/]+$") + if d == "" then return "/" end + return d +end + +--- Directories to check for a read of `path`: the containing directory +--- (or `path` itself when it is a directory), then each parent, up to but +--- EXCLUDING `base` (the session root — its rules are already in the +--- system prompt). Returned outermost-first, so broader rules land before +--- more specific ones. Empty when `path` doesn't exist or isn't strictly +--- under `base`. +function M.walk_dirs(path, base) + if not base then return {} end + local _, real = fs_realpath(path) + if not real then return {} end + local _, st = fs_stat(real) + if not st then return {} end + local dir = (st.type == "directory") and real or dirname(real) + local dirs = {} + while dir ~= base and #dir > #base do + dirs[#dirs + 1] = dir + dir = dirname(dir) + end + if dir ~= base then return {} end -- never reached the root: outside it + -- Reverse: deepest-first -> outermost-first. + for i = 1, #dirs // 2 do + dirs[i], dirs[#dirs - i + 1] = dirs[#dirs - i + 1], dirs[i] + end + return dirs +end + +--- `tool_result` handler for `std.read`: append any not-yet-seen rules +--- files between the read location and the session root to the result +--- (via the writable `output` field), and note them in the transcript by +--- wrapping the read's component. +function M.on_read_result(ev) + if ev.tool_name ~= "std.read" or not root then return end + local ok, input = pcall(panto.ext.json.decode, ev.input or "") + if not ok or type(input) ~= "table" or type(input.path) ~= "string" then return end + local extra = M.collect(M.walk_dirs(input.path, root)) + if #extra == 0 then return end + + local sections = {} + for _, m in ipairs(extra) do + sections[#sections + 1] = "\n\n---\ncontext from " .. m.path .. ":\n\n" .. m.text + end + ev.output = (ev.output or "") .. table.concat(sections) + + -- Visual note: the transcript keeps the read's own output; list the + -- attached files below it. + local comp = ev:get_component() + if comp then + ev:set_component({ + render = function(_, width) + local lines = comp:render(width) + for _, m in ipairs(extra) do + lines[#lines + 1] = "+ rules: " .. m.path + end + return lines + end, + }) + end +end + +function M.activate() + panto.ext.on("session_start", function(ev) + seen = {} + local _, real_cwd = fs_realpath(ev.cwd or uv.cwd()) + root = real_cwd + for _, m in ipairs(M.collect(M.layer_dirs(ev.cwd))) do + panto.ext.agent:add_system_message("context from " .. m.path .. ":\n\n" .. m.text) + end + end) + panto.ext.on("tool_result", M.on_read_result) +end + +return M 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: /panto/agent/skills +-- user: /panto/skills +-- ~/.agent/skills +-- project: /.panto/skills +-- /.agent/skills +-- +-- The `.agent/skills` variants outrank their own layer's normal directory +-- but nothing above it (`~/.agent/skills` does not outrank +-- `/.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 = , 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 -- cgit v1.3