1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
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
|