summaryrefslogtreecommitdiff
path: root/subagents/toml_workflows.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/toml_workflows.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/toml_workflows.lua')
-rw-r--r--subagents/toml_workflows.lua570
1 files changed, 570 insertions, 0 deletions
diff --git a/subagents/toml_workflows.lua b/subagents/toml_workflows.lua
new file mode 100644
index 0000000..fba1930
--- /dev/null
+++ b/subagents/toml_workflows.lua
@@ -0,0 +1,570 @@
+-- subagents/toml_workflows.lua
+--
+-- Persistent TOML workflows: discovery, validation, execution, the generated
+-- `/workflow:<name>` slash commands, and the model-facing `subagents.workflow`
+-- tool. This is the fixed-DAG surface. Output-dependent branching and dynamic
+-- 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 `<cwd>/.panto/workflows/**/*.toml`, with the
+-- project layer shadowing the user layer 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.
+--
+-- 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.
+--
+-- 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.
+
+local workflow = require("subagents.workflow")
+local paths = require("subagents.paths")
+
+local M = {}
+
+-- toml2lua installs its module under the name "toml", not "toml2lua".
+local TOML_MODULE = "toml"
+
+local function host()
+ return require("panto").ext
+end
+
+local function load_toml()
+ local ok, toml = pcall(require, TOML_MODULE)
+ if not ok or type(toml) ~= "table" or type(toml.parse) ~= "function" then
+ return nil, "the 'toml2lua' rock is required to read TOML workflows"
+ end
+ return toml
+end
+
+-- ---------------------------------------------------------------------------
+-- Parsing and validation
+-- ---------------------------------------------------------------------------
+
+local function is_array(value)
+ if type(value) ~= "table" then
+ return false
+ end
+ local count = 0
+ for key in pairs(value) do
+ if type(key) ~= "number" then
+ return false
+ end
+ count = count + 1
+ end
+ return count == #value
+end
+
+local function optional_string(value, label)
+ if value == nil then
+ return nil, nil
+ end
+ if type(value) ~= "string" or value == "" then
+ return nil, label .. " must be a non-empty string when given"
+ end
+ return value, nil
+end
+
+-- validate(def, fallback_name) -> normalized definition | nil, err
+--
+-- 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 } }.
+function M.validate(def, fallback_name)
+ if type(def) ~= "table" then
+ return nil, "workflow definition must be a table"
+ end
+
+ local name, err = optional_string(def.name, "`name`")
+ if err then
+ return nil, err
+ end
+ name = name or fallback_name
+ if name == nil or name == "" then
+ return nil, "workflow has no name"
+ end
+
+ local description
+ description, err = optional_string(def.description, "`description`")
+ if err then
+ return nil, err
+ end
+
+ if not is_array(def.steps) or #def.steps == 0 then
+ return nil, "workflow '" .. name .. "' has no `steps` array"
+ end
+
+ local steps, by_id = {}, {}
+ for index, raw in ipairs(def.steps) do
+ if type(raw) ~= "table" then
+ return nil, string.format("workflow '%s': step %d is not a table", name, index)
+ end
+ local where = string.format("workflow '%s' step %d", name, index)
+ if type(raw.id) ~= "string" or raw.id == "" then
+ return nil, where .. ": `id` is required and must be a non-empty string"
+ end
+ if by_id[raw.id] then
+ return nil, string.format("workflow '%s': duplicate step id '%s'", name, raw.id)
+ end
+ if type(raw.agent) ~= "string" or raw.agent == "" then
+ return nil, string.format("workflow '%s' step '%s': `agent` is required", name, raw.id)
+ end
+ if type(raw.prompt) ~= "string" or raw.prompt == "" then
+ return nil, string.format("workflow '%s' step '%s': `prompt` is required", name, raw.id)
+ end
+
+ local model, model_err = optional_string(raw.model, "`model`")
+ if model_err then
+ return nil, string.format("workflow '%s' step '%s': %s", name, raw.id, model_err)
+ end
+ local reasoning, reasoning_err = optional_string(raw.reasoning, "`reasoning`")
+ if reasoning_err then
+ return nil, string.format("workflow '%s' step '%s': %s", name, raw.id, reasoning_err)
+ end
+
+ local needs = {}
+ if raw.needs ~= nil then
+ if not is_array(raw.needs) then
+ return nil, string.format("workflow '%s' step '%s': `needs` must be an array", name, raw.id)
+ end
+ for _, need in ipairs(raw.needs) do
+ if type(need) ~= "string" or need == "" then
+ return nil, string.format("workflow '%s' step '%s': `needs` entries must be step ids", name, raw.id)
+ end
+ needs[#needs + 1] = need
+ end
+ end
+
+ local step = {
+ id = raw.id,
+ agent = raw.agent,
+ prompt = raw.prompt,
+ model = model,
+ reasoning = reasoning,
+ needs = needs,
+ }
+ steps[#steps + 1] = step
+ by_id[raw.id] = step
+ end
+
+ -- Dependencies must exist before the cycle walk, so an unknown name reports
+ -- itself rather than looking like a broken graph.
+ local terminal = {}
+ for _, step in ipairs(steps) do
+ terminal[step.id] = true
+ end
+ for _, step in ipairs(steps) do
+ for _, need in ipairs(step.needs) do
+ if not by_id[need] then
+ return nil, string.format("workflow '%s' step '%s': unknown dependency '%s'", name, step.id, need)
+ end
+ terminal[need] = nil
+ end
+ end
+
+ -- Iterative-free DFS with a per-node mark: "open" means the node is on the
+ -- current path, so meeting it again is a cycle.
+ local mark = {}
+ local function visit(step, trail)
+ if mark[step.id] == "done" then
+ return true
+ end
+ if mark[step.id] == "open" then
+ return false, string.format(
+ "workflow '%s': dependency cycle through '%s' (%s)",
+ name, step.id, table.concat(trail, " -> ") .. " -> " .. step.id)
+ end
+ mark[step.id] = "open"
+ trail[#trail + 1] = step.id
+ for _, need in ipairs(step.needs) do
+ local ok, cycle_err = visit(by_id[need], trail)
+ if not ok then
+ return false, cycle_err
+ end
+ end
+ trail[#trail] = nil
+ mark[step.id] = "done"
+ return true
+ end
+ for _, step in ipairs(steps) do
+ local ok, cycle_err = visit(step, {})
+ if not ok then
+ return nil, cycle_err
+ end
+ end
+
+ return {
+ name = name,
+ description = description,
+ steps = steps,
+ by_id = by_id,
+ terminal = terminal,
+ }
+end
+
+-- parse(text, fallback_name) -> definition | nil, err, declared_name
+--
+-- On failure the third value is the `name` the document declared, when it read
+-- as one, so discovery can index a broken file under the name it claims rather
+-- than its filename stem — otherwise a broken project file would fail to shadow
+-- the user workflow of the same name and the error would go unreported.
+function M.parse(text, fallback_name)
+ local toml, err = load_toml()
+ if not toml then
+ return nil, err
+ end
+ local ok, parsed = pcall(toml.parse, text, { strict = true })
+ if not ok then
+ return nil, "invalid TOML: " .. tostring(parsed)
+ end
+ if type(parsed) ~= "table" then
+ return nil, "invalid TOML: the document did not parse into a table"
+ end
+ local def, validate_err = M.validate(parsed, fallback_name)
+ if def then
+ return def
+ end
+ local declared = parsed.name
+ if type(declared) ~= "string" or declared == "" then
+ declared = nil
+ end
+ return nil, validate_err, declared
+end
+
+-- ---------------------------------------------------------------------------
+-- Discovery
+-- ---------------------------------------------------------------------------
+
+-- discover() -> { list = ordered array, by_name = map, warnings = array }
+--
+-- Later roots (the project layer) 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
+-- the name it declares (falling back to its stem only when it declares none), so
+-- a broken project workflow reports its error rather than silently letting the
+-- same-named user workflow run in its place.
+function M.discover()
+ local list, by_name, warnings = {}, {}, {}
+
+ -- Walking is the only part that can raise (a missing luv, a hostile
+ -- filesystem); a root that cannot be read contributes a warning and no
+ -- workflows, so discovery as a whole keeps its "never throws" contract.
+ local roots_ok, roots = pcall(paths.config_roots, "workflows")
+ if not roots_ok then
+ return { list = list, by_name = by_name, warnings = { tostring(roots) } }
+ end
+
+ for _, root in ipairs(roots) do
+ local walk_ok, found = pcall(paths.walk, root, ".toml")
+ if not walk_ok then
+ warnings[#warnings + 1] = root .. ": " .. tostring(found)
+ found = {}
+ end
+ for _, path in ipairs(found) do
+ local stem = paths.stem(path)
+ local text, read_err = paths.read_file(path)
+ local entry
+ if not text then
+ entry = { name = stem, path = path, error = tostring(read_err) }
+ else
+ local def, err, declared = M.parse(text, stem)
+ if def then
+ entry = { name = def.name, path = path, definition = def }
+ else
+ entry = { name = declared or stem, path = path, error = tostring(err) }
+ end
+ end
+ if entry.error then
+ warnings[#warnings + 1] = path .. ": " .. entry.error
+ end
+
+ local existing = by_name[entry.name]
+ if existing then
+ for index, candidate in ipairs(list) do
+ if candidate == existing then
+ list[index] = entry
+ break
+ end
+ end
+ else
+ list[#list + 1] = entry
+ end
+ by_name[entry.name] = entry
+ end
+ end
+
+ return { list = list, by_name = by_name, warnings = warnings }
+end
+
+-- ---------------------------------------------------------------------------
+-- Lowering onto the Lua workflow API
+-- ---------------------------------------------------------------------------
+
+local function dependency_text(result)
+ if result == nil then
+ return "[failed: not run]"
+ end
+ if result.status == "completed" then
+ return workflow.output_text(result)
+ end
+ 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.
+local function step_prompt(step, input, settled)
+ local parts = { step.prompt, "\n\n## Workflow input\n\n", input }
+ for _, need in ipairs(step.needs) do
+ parts[#parts + 1] = "\n\n## Output of " .. need .. "\n\n"
+ parts[#parts + 1] = dependency_text(settled[need])
+ end
+ return table.concat(parts)
+end
+
+M.step_prompt = step_prompt
+
+-- lower(def) -> workflow object
+function M.lower(def)
+ return workflow.workflow(function(ctx, input)
+ input = input or ""
+ local waiting = {}
+ for index, step in ipairs(def.steps) do
+ waiting[index] = step
+ end
+
+ local settled = {}
+ local live, live_step = {}, {}
+
+ -- One pass may unblock another (a skip cascades to its dependents), so
+ -- this repeats until nothing more can start or be skipped.
+ local function advance()
+ local changed = true
+ while changed do
+ changed = false
+ local index = 1
+ while index <= #waiting do
+ local step = waiting[index]
+ local ready, skip = true, false
+ for _, need in ipairs(step.needs) do
+ local result = settled[need]
+ if result == nil then
+ ready = false
+ elseif result.status ~= "completed" then
+ skip = true
+ break
+ end
+ end
+
+ if skip then
+ table.remove(waiting, index)
+ settled[step.id] = {
+ status = "skipped",
+ error = "skipped: a dependency did not complete",
+ }
+ changed = true
+ elseif ready then
+ table.remove(waiting, index)
+ local handle = ctx:agent({
+ agent = step.agent,
+ prompt = step_prompt(step, input, settled),
+ model = step.model,
+ reasoning = step.reasoning,
+ })
+ live[#live + 1] = handle
+ live_step[handle] = step.id
+ changed = true
+ else
+ index = index + 1
+ end
+ end
+ end
+ end
+
+ advance()
+ while #live > 0 do
+ local result, remaining = ctx:await(live, "first")
+ if result == nil then
+ break
+ end
+ local still = {}
+ for _, handle in ipairs(remaining or {}) do
+ still[handle] = true
+ end
+ for _, handle in ipairs(live) do
+ if not still[handle] then
+ settled[live_step[handle]] = result
+ break
+ end
+ end
+ live = remaining or {}
+ advance()
+ 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
+ end
+ return out
+ end)
+end
+
+-- run(def, input, profiles) -> array of terminal results
+function M.run(def, input, profiles)
+ return workflow.execute(M.lower(def), input or "", { profiles = profiles })
+end
+
+-- ---------------------------------------------------------------------------
+-- Model- and user-visible formatting
+-- ---------------------------------------------------------------------------
+
+local function format_step(result)
+ return table.concat({
+ "step: " .. tostring(result.id),
+ "status: " .. tostring(result.status),
+ "--- output ---",
+ workflow.output_text(result),
+ }, "\n")
+end
+
+function M.format_results(results)
+ if type(results) ~= "table" or #results == 0 then
+ return "The workflow produced no terminal results."
+ end
+ local blocks = {}
+ for index, result in ipairs(results) do
+ blocks[index] = format_step(result)
+ end
+ return table.concat(blocks, "\n\n")
+end
+
+-- ---------------------------------------------------------------------------
+-- Tool and command entry points
+-- ---------------------------------------------------------------------------
+
+local registry = nil
+
+-- The discovered set, discovered once per activation.
+function M.workflows()
+ if registry == nil then
+ registry = M.discover()
+ end
+ return registry
+end
+
+local function known_names(found)
+ local names = {}
+ for name in pairs(found.by_name) do
+ names[#names + 1] = name
+ end
+ if #names == 0 then
+ return "(no workflows found)"
+ end
+ table.sort(names)
+ return table.concat(names, ", ")
+end
+
+local function run_named(name, input, profiles)
+ local found = M.workflows()
+ local entry = found.by_name[name]
+ if not entry then
+ return "Error: unknown workflow '" .. tostring(name) .. "'; known: " .. known_names(found)
+ end
+ if not entry.definition then
+ return "Error: workflow '" .. name .. "' failed to load: " .. tostring(entry.error)
+ end
+ local ok, results = pcall(M.run, entry.definition, input, profiles)
+ if not ok then
+ return "Error: " .. tostring(results)
+ end
+ 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.
+function M.handle(input, profiles)
+ if type(input) ~= "table" then
+ return "Error: expected an input object"
+ end
+ 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)"
+ end
+
+ if has_name then
+ if type(input.name) ~= "string" or input.name == "" then
+ return "Error: `name` must be a non-empty string"
+ 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)
+end
+
+-- Discover the workflows and register a `/workflow:<name>` command for each
+-- valid one. Invalid files register nothing; their errors stay in the
+-- discovery warnings and surface through `subagents.workflow`.
+function M.discover_and_register(profiles)
+ registry = M.discover()
+ local ext = host()
+ for _, entry in ipairs(registry.list) do
+ if entry.definition then
+ local def = entry.definition
+ ext.register_command({
+ name = "workflow:" .. def.name,
+ description = def.description or ("Run the " .. def.name .. " workflow."),
+ handler = function(args)
+ local ok, results = pcall(M.run, def, args or "", profiles)
+ if not ok then
+ return "[workflow error: " .. tostring(results) .. "]"
+ end
+ return M.format_results(results)
+ end,
+ })
+ end
+ end
+ return registry
+end
+
+return M