-- 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 clears the -- coroutine bindings. Settled progress cards remain attached to their -- transcript entries until the host destroys those components. -- -- 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 DIM, RESET = "\27[2m", "\27[0m" 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 wrap_plain(text, width) width = math.max(1, width or 1) local lines, from = {}, 1 while #text - from + 1 > width do local window = text:sub(from, from + width - 1) local cut = window:match("^.*() ") or width lines[#lines + 1] = text:sub(from, from + cut - 1):gsub("%s+$", "") from = from + cut while from <= #text and text:sub(from, from) == " " do from = from + 1 end end lines[#lines + 1] = text:sub(from) return lines end local function install_header(ext, profiles, workflows) local profile_names, workflow_names = {}, {} for _, profile in ipairs(profiles.list) do profile_names[#profile_names + 1] = profile.name end for _, workflow in ipairs(workflows.list) do if workflow.definition then workflow_names[#workflow_names + 1] = workflow.name end end table.sort(profile_names) table.sort(workflow_names) subscribe(ext, "session_start", function(event) local inner = event:get_component() event:set_component({ render = function(_, width) local lines = inner:render(width) local extras = {} for _, inventory in ipairs({ { "subagents", profile_names }, { "workflows", workflow_names }, }) do local value = #inventory[2] == 0 and "(none)" or table.concat(inventory[2], ", ") for _, line in ipairs(wrap_plain(" " .. inventory[1] .. ": " .. value, width)) do extras[#extras + 1] = DIM .. line .. RESET end end local at = (#lines > 0 and lines[#lines] == "") and #lines or (#lines + 1) for index = #extras, 1, -1 do table.insert(lines, at, extras[index]) end return lines end, }) 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. progress.begin_replay() -- Startup session replay fires tool lifecycle events before the first -- live turn. After that boundary, progress.claim must stay presentation- -- only and never rescan the child catalog for ordinary calls. subscribe(ext, "turn_start", function() progress.begin_live_turn() end) 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) subscribe(ext, "tool_result", function(event) progress.settle(event) end) subscribe(ext, "tool_collapse", function(event) progress.collapse(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) local result = run.handle(input, profiles) progress.settle(context) return result 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. Optional `agents` define profiles available only to this workflow.", 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)." }, agents = { type = "array", description = "Agent profiles available only to this workflow. Inline profiles shadow discovered profiles with the same name.", items = { type = "object", properties = { name = { type = "string", description = "Profile name used by ctx:agent." }, description = { type = "string", description = "Optional human-readable purpose." }, system_prompt = { type = "string", description = "System prompt for this profile." }, }, required = { "name", "system_prompt" }, }, }, }, required = { "prompt", "source" }, }, handler = function(input, context) progress.bind(context) local result = luatool.handle(input, profiles) progress.settle(context) return result 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) local result = toml_workflows.handle(input, profiles) progress.settle(context) return result end, } local workflows = toml_workflows.discover_and_register(profiles) install_header(ext, profiles, workflows) end return { name = "subagents", activate = activate, }