summaryrefslogtreecommitdiff
path: root/subagents/toml_workflows.lua
blob: 0eb2c38e949735e926e9e55c0b54381ce927e568 (plain)
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
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
-- 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: `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.
--
-- 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. 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, unless
-- a top-level `output` array names the steps to report instead.

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 }, report = { id, ... } }.
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

    -- 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

-- 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 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
-- 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 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 }
    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])
    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({
                            name = step.id,
                            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 _, 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 reported 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 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`. 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"
    end
    if type(input.prompt) ~= "string" or input.prompt == "" then
        return "Error: prompt is required and must be a non-empty string"
    end
    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

-- 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
    end
    return nil
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
        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({
                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