summaryrefslogtreecommitdiff
path: root/subagents/toml_workflows.lua
diff options
context:
space:
mode:
Diffstat (limited to 'subagents/toml_workflows.lua')
-rw-r--r--subagents/toml_workflows.lua138
1 files changed, 87 insertions, 51 deletions
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 `<cwd>/.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)"
- 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)
+ 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
- local def, err = M.validate({ name = "transient", steps = input.steps }, "transient")
- if not def then
- return "Error: " .. tostring(err)
+-- 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
- local ok, results = pcall(M.run, def, input.prompt, profiles)
- if not ok then
- return "Error: " .. tostring(results)
+ 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
end
- return M.format_results(results)
+ return nil
end
-- Discover the workflows and register a `/workflow:<name>` 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({