summaryrefslogtreecommitdiff
path: root/subagents/profiles.lua
diff options
context:
space:
mode:
authort <t@tjp.lol>2026-08-16 20:42:43 -0600
committert <t@tjp.lol>2026-08-17 20:31:29 -0600
commit4f0a91ef55fe96835172bdad34feec1e2a0a0977 (patch)
treeed4f3e86575aa6243043bc22f8037be153a609c6 /subagents/profiles.lua
parentc1ab34754d3f3695fafd344fe1a181ecf0740761 (diff)
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.
Diffstat (limited to 'subagents/profiles.lua')
-rw-r--r--subagents/profiles.lua122
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