diff options
Diffstat (limited to 'subagents/profiles.lua')
| -rw-r--r-- | subagents/profiles.lua | 122 |
1 files changed, 122 insertions, 0 deletions
diff --git a/subagents/profiles.lua b/subagents/profiles.lua new file mode 100644 index 0000000..4ae9f91 --- /dev/null +++ b/subagents/profiles.lua @@ -0,0 +1,122 @@ +-- Discover agent profiles: Markdown files with YAML frontmatter. +-- +-- Two layers are read, lowest precedence first: +-- +-- 1. ${XDG_CONFIG_HOME:-$HOME/.config}/panto/agents/**/*.md (user) +-- 2. <cwd>/.panto/agents/**/*.md (project) +-- +-- A project profile shadows a user profile with the same resolved name. Both +-- layers are walked recursively; nesting is organisational only and never part +-- of a profile's name. +-- +-- Recognised frontmatter keys, all optional: +-- +-- name the profile's identity; defaults to the filename stem +-- description one line shown to the primary in the subagents.run schema +-- model full `provider:model` only +-- reasoning passed through as written; the runtime validates it +-- +-- Unknown keys are ignored so a profile written for another harness still +-- loads. A `model` that is not Panto's `provider:model` syntax is dropped with +-- a warning and the child inherits the primary model — a foreign model +-- spelling must not cost the user a working prompt. Nothing else here is +-- fatal either: an unreadable file, broken YAML, or a duplicate name inside +-- one layer produces a warning and discovery continues. Warnings are returned +-- rather than logged because an extension has no logging channel; the caller +-- decides where they surface. +-- +-- Files are read eagerly. Profiles are small and the whole set is needed to +-- build the tool description at activation anyway, so lazy bodies would buy +-- nothing. + +local frontmatter = require("subagents.frontmatter") +local paths = require("subagents.paths") + +local MODEL_PATTERN = "^[^%s:]+:[^%s:]+$" + +local M = {} + +local function trim(s) + return (s:gsub("^%s+", ""):gsub("%s+$", "")) +end + +local function string_field(data, key) + local value = data and data[key] + if type(value) ~= "string" then + return nil + end + value = trim(value) + if value == "" then + return nil + end + return value +end + +-- Build one profile from a file. Appends to `warnings` on anything odd. +local function load_profile(path, layer, warnings) + local text, err = paths.read_file(path) + if not text then + warnings[#warnings + 1] = string.format("profile %s: %s", path, tostring(err)) + return nil + end + + local data, body, warning = frontmatter.parse(text) + local name = string_field(data, "name") or paths.stem(path) + if warning then + warnings[#warnings + 1] = string.format("profile %s: %s", name, warning) + end + + local model = string_field(data, "model") + if model and not model:match(MODEL_PATTERN) then + warnings[#warnings + 1] = + string.format("profile %s: ignoring model '%s' (not provider:model)", name, model) + model = nil + end + + return { + name = name, + description = string_field(data, "description") or "", + model = model, + reasoning = string_field(data, "reasoning"), + body = body, + path = path, + layer = layer, + } +end + +-- discover(roots) -> { list = {...}, by_name = {...}, warnings = {...} } +-- +-- `roots` defaults to the two config layers and exists so tests can point +-- discovery at temporary directories. `list` is sorted by name. +function M.discover(roots) + roots = roots or paths.config_roots("agents") + + local by_name = {} + local warnings = {} + + for _, root in ipairs(roots) do + local seen = {} + for _, path in ipairs(paths.walk(root, ".md")) do + local profile = load_profile(path, root, warnings) + if profile then + if seen[profile.name] then + warnings[#warnings + 1] = string.format( + "profile %s: %s shadows %s in the same layer", + profile.name, path, seen[profile.name]) + end + seen[profile.name] = path + by_name[profile.name] = profile + end + end + end + + local list = {} + for _, profile in pairs(by_name) do + list[#list + 1] = profile + end + table.sort(list, function(a, b) return a.name < b.name end) + + return { list = list, by_name = by_name, warnings = warnings } +end + +return M |
