-- 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 reads the -- layered `[subagents]` settings, 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 session lifecycle: interruption cancels -- live work, ordinary turn boundaries reap only settled jobs so background -- workflows survive, and session teardown cancels everything. 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 paths = require("subagents.paths") local progress = require("subagents.progress") local run = require("subagents.run") local spawn = require("subagents.spawn") local toml_workflows = require("subagents.toml_workflows") local workflow_api = require("subagents.workflow") local MAX_SHOWN_WARNINGS = 5 local DEFAULT_MAX_CONCURRENT = 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 an inline system prompt, 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 from a profile), `system_prompt` (start without a profile), or `id` (continue a child). `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 SUBAGENTS_GUIDANCE = [=[ ## Subagents Use `subagents.run` for one delegation, or emit several `subagents.run` calls in one tool batch for simple independent parallel work. Use `subagents.lua` for dynamic fan-out, branching, staged waves, or work that should continue while you interact with the user. `subagents.lua` takes Lua `source` and optional inline `agents`; it has no shared prompt. Put the complete, specific context each worker needs in that worker's `prompt`. Start a background workflow and save its returned id: ```lua return subagents.workflow(function(ctx) local a = ctx:agent{name="api", system_prompt="Research APIs.", prompt="Inspect the API surface."} local b = ctx:agent{name="tests", system_prompt="Research tests.", prompt="Inspect test coverage."} local research = ctx:await({a, b}, "all") local implementation = ctx:agent{ name="implement", system_prompt="Implement focused changes.", prompt="Implement using this research:\n" .. research[1].output .. "\n" .. research[2].output, }:await() local reviewers = { ctx:agent{name="correctness", system_prompt="Review correctness.", prompt=implementation.output}, ctx:agent{name="simplicity", system_prompt="Review for simplicity.", prompt=implementation.output}, } local reviews = ctx:await(reviewers, "all") return reviews[1].output .. "\n\n" .. reviews[2].output end) ``` **Deterministic code drives; agents advise.** A workflow is code you control wrapped around judgment you cannot. Put every decision that can be nailed down into Lua — loops, counts, ordering, retries, pass/fail gates — and spend agents only on discovery and judgment. When Lua needs a fact an agent found (a list to fan out over, a verdict, a chosen option), tell that agent to end its report with one machine-parseable line, parse it, and let the parsed value shape the rest of the graph. The sandbox has `json.encode(value)` and `json.decode(text)`, which returns the value or `nil, message`. ```lua return subagents.workflow(function(ctx) local found = ctx:agent{ name="inventory", system_prompt="Inventory work items. Read-only.", prompt="List every open PR in owner/repo. End with exactly one line: RESULT={\"prs\":[]}", }:await() local line = found.output and found.output:match("RESULT=([^\n]+)") local items = line and json.decode(line) if not items or not items.prs then return "inventory produced no parseable RESULT line" end local reviewers = {} for _, pr in ipairs(items.prs) do reviewers[#reviewers + 1] = ctx:agent{ name = "review-" .. pr, system_prompt = "Review one pull request.", prompt = "Review PR #" .. pr .. " in owner/repo.", } end local reviews, out = ctx:await(reviewers, "all"), {} for _, review in ipairs(reviews) do out[#out + 1] = review.output end return table.concat(out, "\n\n") end) ``` Every `ctx:agent` needs a non-empty `name` unique within that workflow. A workflow callback must return a string. Inspect a running or finished workflow with a later `subagents.lua` call, for example: ```lua local w = subagents.workflows["workflow-1"] return w.status .. (w.result and ("\n" .. w.result) or "") ``` Workflow fields are `id`, `status`, `result`, `error`, and `agents`. Agents are available by name or iteration and expose `name`, `status`, `output`, `error`, and `id`. Records are read-only. Workflow completion wakes you with its id; use another `subagents.lua` call to retrieve whichever outputs you need. ]=] local function install_guidance(ext) subscribe(ext, "session_start", function() local primary = ext.agent if primary and type(primary.add_system_message) == "function" then primary:add_system_message(SUBAGENTS_GUIDANCE) end end) end local function configured_max_concurrent(ext) local value = DEFAULT_MAX_CONCURRENT local layers = ext.dirs and ext.dirs.layers if type(layers) ~= "table" then return value end local ok_toml, toml = pcall(require, "toml") if not ok_toml or type(toml) ~= "table" or type(toml.parse) ~= "function" then error("panto-subagents: the 'toml2lua' rock is required to read configuration") end for _, layer in ipairs(layers) do if type(layer) == "table" and type(layer.dir) == "string" then local path = layer.dir .. "/config.toml" local text = paths.read_file(path) if text then local parsed_ok, parsed = pcall(toml.parse, text, { strict = true }) if not parsed_ok or type(parsed) ~= "table" then error("panto-subagents: could not parse " .. path) end local section = parsed.subagents if section ~= nil and type(section) ~= "table" then error("panto-subagents: [subagents] must be a table in " .. path) end local configured = section and section.max_concurrent if configured ~= nil then if type(configured) ~= "number" or configured < 1 or configured ~= math.floor(configured) then error("panto-subagents: subagents.max_concurrent must be a positive integer in " .. path) end value = configured end end end end return value 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 jobs.MAX_CONCURRENT = configured_max_concurrent(ext) -- 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() workflow_api.cancel_all(false) jobs.cancel_all() end) subscribe(ext, "turn_end", function() jobs.reap() progress.reset() end) subscribe(ext, "session_end", function() workflow_api.cancel_all(true) jobs.cancel_all() 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 `system_prompt` and `id`." }, system_prompt = { type = "string", description = "System prompt for a new child without a profile. Mutually exclusive with `agent` and `id`." }, id = { type = "string", description = "Id of a child started earlier in this session, to continue it. Mutually exclusive with `agent` and `system_prompt`." }, 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; query available options with `subagents.models`." }, 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, or `provider`/`query` for a bounded search.", 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." }, }, }, handler = function(input) return models.handle(input) end, } ext.register_tool { name = "subagents.lua", description = "Run sandboxed Lua that starts background subagent workflows or inspects existing ones. `source` can call subagents.workflow(function(ctx) ... end), which returns a workflow id immediately, and can read immutable records from subagents.workflows. Optional `agents` define profiles available only to workflows started by this call.", schema = { type = "object", properties = { source = { type = "string", description = "Lua source that starts a workflow or inspects subagents.workflows." }, 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 = { "source" }, }, handler = function(input, context) progress.bind(context) local result = luatool.handle(input, profiles, context) progress.settle(context) return result end, } ext.register_tool { name = "subagents.workflow", description = "Run a discovered TOML workflow by `name`: a fixed dependency graph of subagents. Every step receives `prompt` as the workflow input, and a step with `needs` also receives those steps' outputs. For dynamic graphs, use `subagents.lua`.", schema = { type = "object", properties = { name = { type = "string", description = "Name of a discovered workflow." }, prompt = { type = "string", description = "The workflow input, given to every step." }, }, required = { "name", "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_guidance(ext) install_header(ext, profiles, workflows) end return { name = "subagents", activate = activate, }