From 4f0a91ef55fe96835172bdad34feec1e2a0a0977 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 16 Aug 2026 20:42:43 -0600 Subject: subagents extension on the generic host surfaces The rock now owns all subagent policy on top of libpanto-lua's generic APIs: children are ordinary panto.agent instances over rock-constructed stores, started with agent:run_async and awaited by arming uv.new_poll on each job's wake_fd from the tool handler's coroutine. subagents/jobs.lua carries the session policy the host used to own: the concurrency gate (4 running, FIFO queue, cancel-while-queued never starts), the await contract (results in input order; "first" returns settled plus remaining by identity), and settle-time shaping. subagents/spawn.lua seeds new children (primary system context, child role, profile body with manifest metadata), resolves model/reasoning through panto.ext.resolve_model, filters subagents.* out of the inherited tool set via agent:set_tools, and reads resume defaults back from stored message metadata. One-shot structured workers are a null_store agent with a declaration-only output tool, tool_choice forced, dispatch_tools=false. subagents/progress.lua renders per-tool-entry cards through the component handle's invalidate seam; turn_interrupt cancels live children, turn_end closes them. Spec suite rewritten against fakes of the new surfaces (98 cases), including gate/queue/cancel bounds, resume-default extraction, one-shot capture via unresolved tool calls, tool filtering, and manifest seeding. --- subagents/paths.lua | 169 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 169 insertions(+) create mode 100644 subagents/paths.lua (limited to 'subagents/paths.lua') diff --git a/subagents/paths.lua b/subagents/paths.lua new file mode 100644 index 0000000..678e241 --- /dev/null +++ b/subagents/paths.lua @@ -0,0 +1,169 @@ +-- Filesystem and session-path helpers shared by the subagents extension. +-- +-- Two jobs live here: +-- +-- 1. Config-layer discovery. `config_roots("agents")` returns the two +-- directories profiles (or workflows) are read from, lowest precedence +-- first: `${XDG_CONFIG_HOME:-$HOME/.config}/panto/` and +-- `/.panto/`. A root whose base cannot be resolved (no HOME +-- and no XDG_CONFIG_HOME) is simply omitted, so callers must not assume +-- two entries. `walk` then collects `**/*.` beneath a root. +-- +-- 2. The child session store. `child_store_dir()` derives +-- `/subagents/` from the host's +-- `session_info()`, and `ensure_dir` creates it before a store is opened +-- there. Nothing here duplicates panto's XDG / PANTO_SESSION_DIR / +-- cwd-encoding logic; the per-cwd session directory arrives ready-made +-- from the host. +-- +-- Edge cases: a missing or unreadable directory yields no files rather than an +-- error, because both config layers are optional. Directory recursion is +-- capped (MAX_DEPTH) so a symlink cycle cannot hang discovery. Entries whose +-- type the platform does not report during scandir are stat'ed individually, +-- which also means a symlinked directory is followed like a real one. Results +-- are sorted so discovery order — and therefore shadowing — is deterministic. +-- +-- `luv` is resolved with pcall: only the filesystem helpers need it, so a +-- host or test run without luv can still use the rest of the extension and +-- gets a clear error if it actually walks the filesystem. + +local ok_uv, uv = pcall(require, "luv") + +local MAX_DEPTH = 16 + +local M = {} + +local function host() + return require("panto").ext +end + +local function require_uv() + if not ok_uv then + error("panto-subagents: the 'luv' module is required for filesystem discovery", 2) + end + return uv +end + +-- Current working directory, i.e. the project root of this panto session. +function M.cwd() + return require_uv().cwd() +end + +-- Extension-less basename of a path: "/a/b/reviewer.md" -> "reviewer". +function M.stem(path) + local base = path:match("[^/]+$") or path + return (base:gsub("%.[^.]+$", "")) +end + +-- Whole-file read. Returns nil, err for a file that cannot be opened. +function M.read_file(path) + local fh, err = io.open(path, "r") + if not fh then + return nil, err or ("could not open " .. path) + end + local data = fh:read("a") + fh:close() + if data == nil then + return nil, "could not read " .. path + end + return data +end + +-- User layer first, project layer second: later roots shadow earlier ones. +function M.config_roots(name) + local roots = {} + local config_home = os.getenv("XDG_CONFIG_HOME") + if config_home == nil or config_home == "" then + local home = os.getenv("HOME") + if home ~= nil and home ~= "" then + config_home = home .. "/.config" + else + config_home = nil + end + end + if config_home ~= nil then + roots[#roots + 1] = config_home .. "/panto/" .. name + end + local cwd = M.cwd() + if cwd ~= nil and cwd ~= "" then + roots[#roots + 1] = cwd .. "/.panto/" .. name + end + return roots +end + +-- Every file at or below `root` whose name ends with `suffix`, sorted. +function M.walk(root, suffix) + local lib = require_uv() + local found = {} + + local function visit(dir, depth) + if depth > MAX_DEPTH then + return + end + local req = lib.fs_scandir(dir) + if not req then + return + end + while true do + local name, kind = lib.fs_scandir_next(req) + if not name then + break + end + local path = dir .. "/" .. name + if kind ~= "directory" and kind ~= "file" then + local stat = lib.fs_stat(path) + kind = stat and stat.type or kind + end + if kind == "directory" then + visit(path, depth + 1) + elseif kind == "file" and name:sub(-#suffix) == suffix then + found[#found + 1] = path + end + end + end + + visit(root, 1) + table.sort(found) + return found +end + +-- Create `path` and every missing parent, tolerating one that already exists. +-- A store cannot be opened on a directory that is not there yet, and the child +-- catalog is two levels below a session directory panto may itself have only +-- just made. Returns nil, err rather than raising: this runs inside a spawn, +-- where a failure is the child's failure to report. +function M.ensure_dir(path) + if not ok_uv then + return nil, "panto-subagents: the 'luv' module is required to create the child store directory" + end + if type(path) ~= "string" or path == "" then + return nil, "no child store directory to create" + end + local made = path:sub(1, 1) == "/" and "" or "." + for segment in path:gmatch("[^/]+") do + made = made .. "/" .. segment + if uv.fs_stat(made) == nil then + local ok, err = uv.fs_mkdir(made, 493) -- 0755 + -- A racing writer is fine; only a directory that still is not + -- there afterwards is a failure. + if not ok and uv.fs_stat(made) == nil then + return nil, tostring(err) + end + end + end + return true +end + +-- The per-primary child catalog: /subagents/. +-- Returns the directory plus the session info it came from, so a caller that +-- also needs the owning session id does not ask twice. This is pure string +-- assembly; `ensure_dir` creates it at spawn time. +function M.child_store_dir() + local info = host().session_info() + if type(info) ~= "table" or type(info.session_dir) ~= "string" or type(info.session_id) ~= "string" then + return nil, "no primary session information available" + end + return info.session_dir .. "/subagents/" .. info.session_id, info +end + +return M -- cgit v1.3