From d1306506aa7f504b0e91c9c6ed7314afbf99978e Mon Sep 17 00:00:00 2001 From: t Date: Wed, 19 Aug 2026 19:02:30 -0600 Subject: Add background Lua workflows, inline child prompts, and layered config subagents.lua now starts a workflow on its own coroutine and returns a session-scoped id immediately, so fan-out continues while the primary keeps working; completion wakes the primary, and later calls read immutable records from subagents.workflows. Every ctx:agent takes a workflow-unique name so those records are addressable. A session_start guidance message tells the primary when to reach for run vs lua. Children no longer inherit the primary's system context: a child starts from the fixed child-role instruction plus its profile, and subagents.run/ctx:agent accept an inline system_prompt instead of a profile. The now-redundant `agent` form of subagents.models is gone. Concurrency defaults to five and is configurable through [subagents] max_concurrent in any layered config.toml; turn boundaries reap only settled jobs so background workflows survive, while interrupt and session end cancel. Config roots come from panto.ext.dirs.layers rather than a hand-rolled XDG lookup, which picks up the base and git-ignored local layers for both agents/ and workflows/. TOML workflows tighten up: the subagents.workflow tool takes a discovered name only (inline `steps` duplicated subagents.lua at less power), an optional top-level `output` array chooses the reported steps and their order instead of the terminal set, a step with no workflow input gets no empty input heading, and a workflow naming an undiscovered agent is rejected at discovery rather than part-way through a run. --- subagents/toml_workflows.lua | 138 +++++++++++++++++++++++++++---------------- 1 file changed, 87 insertions(+), 51 deletions(-) (limited to 'subagents/toml_workflows.lua') diff --git a/subagents/toml_workflows.lua b/subagents/toml_workflows.lua index fba1930..0eb2c38 100644 --- a/subagents/toml_workflows.lua +++ b/subagents/toml_workflows.lua @@ -6,33 +6,33 @@ -- fan-out stay in the Lua API (subagents/workflow.lua); TOML deliberately does -- not grow into a programming language. -- --- Discovery mirrors profiles: `${XDG_CONFIG_HOME:-$HOME/.config}/panto/ --- workflows/**/*.toml` first, then `/.panto/workflows/**/*.toml`, with the --- project layer shadowing the user layer by resolved name (the `name` field, --- defaulting to the file stem). +-- Discovery mirrors profiles: `workflows/**/*.toml` beneath every host config +-- layer (base, user, project, local), a later layer shadowing an earlier one by +-- resolved name (the `name` field, defaulting to the file stem). -- -- Validation runs before any inference: a workflow needs a non-empty `steps` -- array, every step needs a unique `id`, an `agent`, and a `prompt`, every -- entry in `needs` must name a declared step, and the dependency graph must be -- acyclic. Discovery itself never throws — an invalid file is recorded with its -- error and registers no command, and `subagents.workflow` reports that error --- if the model asks for the workflow by name. The same validator checks a --- transient `steps` definition passed straight to the tool. +-- if the model asks for the workflow by name. -- -- Execution lowers onto the Lua job primitives. Every step's prompt is its own -- text, then the workflow input, then one labeled section per dependency in -- `needs` order. All ready steps start at once; as each settles, any dependent -- whose needs are now satisfied starts immediately, so unrelated branches keep -- running. A step whose dependency did not complete is marked "skipped" and --- never spawns, and that skip cascades transitively. Terminal steps — those no --- other step depends on — are returned in declaration order, which keeps the --- output stable regardless of settle order. +-- never spawns, and that skip cascades transitively. What comes back is the +-- steps `output` names, in its order, or — without it — every terminal step +-- (one no other step depends on) in declaration order. Either way the reported +-- order is fixed by the file, not by settle order. -- -- Edge cases: the TOML parser returns nil rather than raising for some -- malformed documents, so a non-table parse result is treated as a parse -- error. A workflow input may legitimately be empty (a bare `/workflow:name` -- with no tail), which is passed through as an empty string rather than --- rejected. A workflow whose steps are all terminal returns every step. +-- rejected. A workflow whose steps are all terminal returns every step, unless +-- a top-level `output` array names the steps to report instead. local workflow = require("subagents.workflow") local paths = require("subagents.paths") @@ -86,7 +86,7 @@ end -- -- The returned definition is a fresh table, so a caller can trust its shape: -- { name, description, steps = { { id, agent, prompt, model, reasoning, --- needs }, ... }, terminal = { [id] = true } }. +-- needs }, ... }, terminal = { [id] = true }, report = { id, ... } }. function M.validate(def, fallback_name) if type(def) ~= "table" then return nil, "workflow definition must be a table" @@ -210,12 +210,42 @@ function M.validate(def, fallback_name) end end + -- What the workflow reports. `output` names the steps explicitly, in the + -- order it lists them; without it, every terminal step in declaration order. + local report = {} + if def.output ~= nil then + if not is_array(def.output) or #def.output == 0 then + return nil, string.format("workflow '%s': `output` must be a non-empty array of step ids", name) + end + local seen = {} + for _, id in ipairs(def.output) do + if type(id) ~= "string" or id == "" then + return nil, string.format("workflow '%s': `output` entries must be step ids", name) + end + if not by_id[id] then + return nil, string.format("workflow '%s': `output` names unknown step '%s'", name, id) + end + if seen[id] then + return nil, string.format("workflow '%s': `output` names '%s' twice", name, id) + end + seen[id] = true + report[#report + 1] = id + end + else + for _, step in ipairs(steps) do + if terminal[step.id] then + report[#report + 1] = step.id + end + end + end + return { name = name, description = description, steps = steps, by_id = by_id, terminal = terminal, + report = report, } end @@ -254,7 +284,7 @@ end -- discover() -> { list = ordered array, by_name = map, warnings = array } -- --- Later roots (the project layer) shadow earlier ones by resolved name. An +-- Later roots (the more local layers) shadow earlier ones by resolved name. An -- unreadable or invalid file never aborts discovery: it becomes a warning, and -- its name maps to a definition-less entry carrying the error so the tool can -- explain the failure if the model asks for it. An invalid file shadows under @@ -328,10 +358,16 @@ local function dependency_text(result) return "[failed: " .. tostring(result.error or result.status or "unknown") .. "]" end --- The exact prompt a step receives: its own text, the workflow input, then one --- labeled section per dependency in `needs` order. +-- The exact prompt a step receives: its own text, the workflow input when there +-- is one, then one labeled section per dependency in `needs` order. An +-- unparameterized workflow (a bare `/workflow:name`) gets no input heading +-- rather than an empty one. local function step_prompt(step, input, settled) - local parts = { step.prompt, "\n\n## Workflow input\n\n", input } + local parts = { step.prompt } + if input ~= nil and input ~= "" then + parts[#parts + 1] = "\n\n## Workflow input\n\n" + parts[#parts + 1] = input + end for _, need in ipairs(step.needs) do parts[#parts + 1] = "\n\n## Output of " .. need .. "\n\n" parts[#parts + 1] = dependency_text(settled[need]) @@ -383,6 +419,7 @@ function M.lower(def) elseif ready then table.remove(waiting, index) local handle = ctx:agent({ + name = step.id, agent = step.agent, prompt = step_prompt(step, input, settled), model = step.model, @@ -419,22 +456,20 @@ function M.lower(def) end local out = {} - for _, step in ipairs(def.steps) do - if def.terminal[step.id] then - local result = settled[step.id] or { status = "skipped", error = "skipped: never started" } - out[#out + 1] = { - id = step.id, - status = result.status, - output = result.output, - error = result.error, - } - end + for _, id in ipairs(def.report) do + local result = settled[id] or { status = "skipped", error = "skipped: never started" } + out[#out + 1] = { + id = id, + status = result.status, + output = result.output, + error = result.error, + } end return out end) end --- run(def, input, profiles) -> array of terminal results +-- run(def, input, profiles) -> array of reported results function M.run(def, input, profiles) return workflow.execute(M.lower(def), input or "", { profiles = profiles }) end @@ -454,7 +489,7 @@ end function M.format_results(results) if type(results) ~= "table" or #results == 0 then - return "The workflow produced no terminal results." + return "The workflow produced no results." end local blocks = {} for index, result in ipairs(results) do @@ -505,8 +540,8 @@ local function run_named(name, input, profiles) return M.format_results(results) end --- The `subagents.workflow` tool: run a discovered workflow by `name`, or a --- transient definition supplied as `steps`. Exactly one of the two. +-- The `subagents.workflow` tool: run a discovered workflow by `name`. Dynamic +-- graphs belong in `subagents.lua`, which is strictly more capable. function M.handle(input, profiles) if type(input) ~= "table" then return "Error: expected an input object" @@ -514,32 +549,27 @@ function M.handle(input, profiles) if type(input.prompt) ~= "string" or input.prompt == "" then return "Error: prompt is required and must be a non-empty string" end - - local has_name = input.name ~= nil - local has_steps = input.steps ~= nil - if has_name and has_steps then - return "Error: pass exactly one of `name` (a discovered workflow) or `steps` (a transient one), not both" - end - if not has_name and not has_steps then - return "Error: pass exactly one of `name` (a discovered workflow) or `steps` (a transient one)" + if type(input.name) ~= "string" or input.name == "" then + return "Error: `name` is required and must be a non-empty string" end + return run_named(input.name, input.prompt, profiles) +end - if has_name then - if type(input.name) ~= "string" or input.name == "" then - return "Error: `name` must be a non-empty string" +-- The first step naming a profile that was not discovered, if any. Agent names +-- cannot be checked by `validate` (it knows nothing about profiles), but they +-- can be checked here, once, rather than after a spawn has already burned the +-- tokens of every step that ran before the bad one. +local function missing_agent(def, profiles) + if type(profiles) ~= "table" or type(profiles.by_name) ~= "table" then + return nil + end + for _, step in ipairs(def.steps) do + if profiles.by_name[step.agent] == nil then + return string.format("workflow '%s' step '%s': unknown agent '%s'", + def.name, step.id, step.agent) end - return run_named(input.name, input.prompt, profiles) - end - - local def, err = M.validate({ name = "transient", steps = input.steps }, "transient") - if not def then - return "Error: " .. tostring(err) end - local ok, results = pcall(M.run, def, input.prompt, profiles) - if not ok then - return "Error: " .. tostring(results) - end - return M.format_results(results) + return nil end -- Discover the workflows and register a `/workflow:` command for each @@ -549,6 +579,12 @@ function M.discover_and_register(profiles) registry = M.discover() local ext = host() for _, entry in ipairs(registry.list) do + local unknown = entry.definition and missing_agent(entry.definition, profiles) + if unknown then + entry.definition = nil + entry.error = unknown + registry.warnings[#registry.warnings + 1] = tostring(entry.path) .. ": " .. unknown + end if entry.definition then local def = entry.definition ext.register_command({ -- cgit v1.3