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. --- init.lua | 213 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 213 insertions(+) create mode 100644 init.lua (limited to 'init.lua') diff --git a/init.lua b/init.lua new file mode 100644 index 0000000..5f28308 --- /dev/null +++ b/init.lua @@ -0,0 +1,213 @@ +-- panto-subagents: the `subagents` extension entry point. +-- +-- Activation happens once per panto session (a new session, `/new`, or +-- `/resume` rebuilds the whole Lua state and runs this again). It discovers +-- the agent profiles once, registers the four model-facing tools, and +-- registers a `/workflow:` command for every valid TOML workflow. +-- +-- Discovery order is deterministic and side-effect free apart from +-- registration, because pantograph evaluates every candidate extension file +-- but only activates the ones its allow/deny policy permits. +-- +-- The discovered profile set is captured here and threaded into the run and +-- models handlers, so both tools describe and resolve exactly the profiles +-- named in the tool description the model was shown. Profile warnings (a +-- foreign `model` spelling, a duplicate name in one layer) have no logging +-- channel in an extension, so a bounded number of them ride along at the end +-- of the run description — the one place the user and the model both see the +-- profile list. +-- +-- Activation also subscribes to the turn lifecycle: an interrupted turn asks +-- every live child to stop, and the end of a turn joins them and drops the +-- progress cards, which are presentation state that must not survive the turn +-- that produced it. +-- +-- If the host predates the model-resolution seam, activation fails loudly +-- instead of registering tools that cannot work. + +local jobs = require("subagents.jobs") +local luatool = require("subagents.luatool") +local models = require("subagents.models") +local progress = require("subagents.progress") +local run = require("subagents.run") +local spawn = require("subagents.spawn") +local toml_workflows = require("subagents.toml_workflows") + +local MAX_SHOWN_WARNINGS = 5 + +local function host() + return require("panto").ext +end + +-- Events are optional: a host without the bus, or a print-mode session with no +-- components, simply never calls back and every child still runs. +local function subscribe(ext, name, handler) + if type(ext.on) == "function" then + pcall(ext.on, name, handler) + end +end + +local function run_description(profiles) + local lines = { + "Delegate a task to a subagent and wait for its report. Start a new child from an agent profile, or continue a child you started earlier in this session.", + "", + "Agent profiles:", + } + if #profiles.list == 0 then + lines[#lines + 1] = " (none found; define them in .panto/agents/*.md)" + else + for _, profile in ipairs(profiles.list) do + if profile.description ~= "" then + lines[#lines + 1] = string.format(" %s — %s", profile.name, profile.description) + else + lines[#lines + 1] = " " .. profile.name + end + end + end + + lines[#lines + 1] = "" + lines[#lines + 1] = "Rules:" + lines[#lines + 1] = "- Pass exactly one of `agent` (start a new child from that profile) or `id` (continue a child from an earlier result). `prompt` is always required and must be non-empty." + lines[#lines + 1] = "- Omit `model` and `reasoning` normally; a child inherits yours. Call subagents.models before choosing an unfamiliar model or reasoning level." + lines[#lines + 1] = "- A child shares your workspace and tools but not your conversation, and cannot ask the user questions. Put every piece of task context it needs into `prompt`." + lines[#lines + 1] = "- To delegate in parallel, emit several subagents.run calls in one tool batch; they run concurrently and one failure does not discard the others." + lines[#lines + 1] = "- Every started child reports an id. Pass that id back to continue the same conversation." + + if #profiles.warnings > 0 then + lines[#lines + 1] = "" + lines[#lines + 1] = "Profile warnings:" + for index, warning in ipairs(profiles.warnings) do + if index > MAX_SHOWN_WARNINGS then + lines[#lines + 1] = string.format(" (%d more)", #profiles.warnings - MAX_SHOWN_WARNINGS) + break + end + lines[#lines + 1] = " " .. warning + end + end + + return table.concat(lines, "\n") +end + +local function activate() + local ext = host() + if type(ext.resolve_model) ~= "function" or type(require("panto").agent) ~= "function" then + error("panto-subagents: this pantograph is too old for subagents (panto.ext.resolve_model is missing)") + end + + -- Discover through spawn so the workflow lanes, which resolve profiles + -- lazily, share the exact set this tool description advertises. + local profiles = spawn.profiles() + + -- Escape reaches the children through the turn, not through a tool: the + -- primary is parked inside a tool call when they are running. + subscribe(ext, "turn_interrupt", function() + jobs.cancel_all() + end) + subscribe(ext, "turn_end", function() + jobs.close_all() + progress.reset() + end) + -- The entry for a delegation call is where that call's children render. + subscribe(ext, "tool_call_complete", function(event) + progress.claim(event) + end) + + ext.register_tool { + name = "subagents.run", + description = run_description(profiles), + schema = { + type = "object", + properties = { + agent = { type = "string", description = "Profile name for a new child. Mutually exclusive with `id`." }, + id = { type = "string", description = "Id of a child started earlier in this session, to continue it. Mutually exclusive with `agent`." }, + prompt = { type = "string", description = "The complete task for the child. It sees none of this conversation." }, + model = { type = "string", description = "Optional `provider:model` override. Omit to inherit." }, + reasoning = { type = "string", description = "Optional reasoning level override. Omit to inherit." }, + }, + required = { "prompt" }, + }, + handler = function(input, context) + progress.bind(context) + return run.handle(input, profiles) + end, + } + + ext.register_tool { + name = "subagents.models", + description = "Query the configured model catalog: no arguments for the inherited model plus provider counts, `model` for an exact lookup including valid reasoning levels, `provider`/`query` for a bounded search, or `agent` for what a profile will actually run on.", + schema = { + type = "object", + properties = { + provider = { type = "string", description = "Restrict a search to one provider." }, + query = { type = "string", description = "Substring to search model names for." }, + limit = { type = "integer", description = "Maximum matches to return (1-50, default 10).", minimum = 1, maximum = 50 }, + model = { type = "string", description = "Exact `provider:model` to look up." }, + agent = { type = "string", description = "Agent profile whose effective model to report." }, + }, + }, + handler = function(input) + return models.handle(input, profiles) + end, + } + + ext.register_tool { + name = "subagents.lua", + description = "Run a one-off Lua workflow that fans several subagents out and combines their results. `source` must return subagents.workflow(function(ctx, input) ... end) and runs in a restricted environment with no filesystem, process, or module access.", + schema = { + type = "object", + properties = { + prompt = { type = "string", description = "The workflow input, passed to the callback as its second argument." }, + source = { type = "string", description = "Lua source returning subagents.workflow(function(ctx, input) ... end)." }, + }, + required = { "prompt", "source" }, + }, + handler = function(input, context) + progress.bind(context) + return luatool.handle(input, profiles) + end, + } + + ext.register_tool { + name = "subagents.workflow", + description = "Run a fixed dependency graph of subagents: `name` runs a discovered TOML workflow, or `steps` defines one inline. Every step receives `prompt` as the workflow input, and a step with `needs` also receives those steps' outputs.", + schema = { + type = "object", + properties = { + name = { type = "string", description = "Name of a discovered workflow. Mutually exclusive with `steps`." }, + prompt = { type = "string", description = "The workflow input, given to every step." }, + steps = { + type = "array", + description = "Inline workflow definition. Mutually exclusive with `name`.", + items = { + type = "object", + properties = { + id = { type = "string", description = "Unique step id." }, + agent = { type = "string", description = "Agent profile to run this step." }, + prompt = { type = "string", description = "Step instruction." }, + model = { type = "string", description = "Optional `provider:model` override." }, + reasoning = { type = "string", description = "Optional reasoning level override." }, + needs = { + type = "array", + description = "Ids of steps whose output this step receives.", + items = { type = "string" }, + }, + }, + required = { "id", "agent", "prompt" }, + }, + }, + }, + required = { "prompt" }, + }, + handler = function(input, context) + progress.bind(context) + return toml_workflows.handle(input, profiles) + end, + } + + toml_workflows.discover_and_register(profiles) +end + +return { + name = "subagents", + activate = activate, +} -- cgit v1.3