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/profiles.lua | 122 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 subagents/profiles.lua (limited to 'subagents/profiles.lua') 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. /.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 -- cgit v1.3