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. --- spec/test_toml_workflows.lua | 161 ++++++++++++++++++++++++++++++------------- 1 file changed, 112 insertions(+), 49 deletions(-) (limited to 'spec/test_toml_workflows.lua') diff --git a/spec/test_toml_workflows.lua b/spec/test_toml_workflows.lua index 4755b8e..4ec4803 100644 --- a/spec/test_toml_workflows.lua +++ b/spec/test_toml_workflows.lua @@ -91,6 +91,20 @@ return { assert(def.by_id.b.needs[1] == "a") end }, + { "`output` chooses the reported steps, in its own order", function() + local def = assert(toml_workflows.validate({ + name = "branch", + steps = BRANCH_DEF.steps, + output = { "c", "a" }, + }, "fallback")) + assert(def.report[1] == "c" and def.report[2] == "a" and #def.report == 2, + "the file's order wins: " .. table.concat(def.report, ",")) + + local default = assert(toml_workflows.validate(BRANCH_DEF, "fallback")) + assert(default.report[1] == "b" and default.report[2] == "c" and #default.report == 2, + "without `output`, terminal steps in declaration order") + end }, + { "the name falls back to the file stem", function() local def = assert(toml_workflows.validate({ steps = BRANCH_DEF.steps }, "from-stem")) assert(def.name == "from-stem", tostring(def.name)) @@ -108,6 +122,10 @@ return { bad({ steps = { { id = "a", agent = "alpha" } } }, "`prompt` is required") bad({ steps = { { id = "a", prompt = "p" } } }, "`agent` is required") bad({ steps = { { agent = "alpha", prompt = "p" } } }, "`id` is required") + bad({ steps = BRANCH_DEF.steps, output = {} }, "`output` must be a non-empty array") + bad({ steps = BRANCH_DEF.steps, output = "b" }, "`output` must be a non-empty array") + bad({ steps = BRANCH_DEF.steps, output = { "ghost" } }, "`output` names unknown step 'ghost'") + bad({ steps = BRANCH_DEF.steps, output = { "b", "b" } }, "`output` names 'b' twice") bad({ steps = { { id = "a", agent = "alpha", prompt = "p" }, { id = "a", agent = "beta", prompt = "q" }, @@ -143,6 +161,11 @@ return { "", "[failed: boom]", }, "\n"), string.format("unexpected prompt:\n%s", prompt)) + + -- An unparameterized workflow gets no input heading at all. + local bare = toml_workflows.step_prompt( + { id = "a", agent = "alpha", prompt = "Do it.", needs = {} }, "", {}) + assert(bare == "Do it.", string.format("unexpected bare prompt:\n%s", bare)) end }, { "a dependent receives its dependency's output and runs after it", function() @@ -167,6 +190,26 @@ return { end) end }, + { "`output` reports an intermediate step instead of the terminal one", function() + with_host(function(handle, profiles) + local def = assert(toml_workflows.validate({ + name = "chain", + steps = { + { id = "a", agent = "alpha", prompt = "Inspect." }, + { id = "b", agent = "beta", prompt = "Summarize.", needs = { "a" } }, + }, + output = { "a" }, + }, "chain")) + handle.queue_for("alpha", { output = "A output" }) + handle.queue_for("beta", { output = "B output" }) + + local results = toml_workflows.run(def, "the input", profiles) + assert(#handle.spawns == 2, "both steps still run") + assert(#results == 1 and results[1].id == "a", "only the named step is reported") + assert(results[1].output == "A output") + end) + end }, + { "a failed step skips its dependents while other branches finish", function() with_host(function(handle, profiles) local def = assert(toml_workflows.validate(BRANCH_DEF, "branch")) @@ -319,6 +362,29 @@ return { end) end }, + { "a workflow naming an undiscovered agent is rejected at discovery", function() + return with_layers({ + ["project/workflows/ghosted.toml"] = table.concat({ + '[[steps]]', + 'id = "one"', + 'agent = "nobody"', + 'prompt = "Do it."', + }, "\n"), + }, function() + with_host(function(handle, profiles) + local found = toml_workflows.discover_and_register(profiles) + local entry = found.by_name["ghosted"] + assert(entry and entry.definition == nil, "the definition must not survive") + has(entry.error, "unknown agent 'nobody'") + assert(#handle.commands == 0, "no command is registered for it") + has(table.concat(found.warnings, " | "), "unknown agent 'nobody'") + has(toml_workflows.handle({ name = "ghosted", prompt = "x" }, profiles), + "unknown agent 'nobody'") + assert(#handle.spawns == 0, "nothing ran before the failure") + end) + end) + end }, + { "a broken project workflow shadows the user one under its declared name", function() local user_valid = table.concat({ 'name = "dup"', @@ -367,65 +433,62 @@ return { local ok, err = pcall(toml_workflows.run, def, "input", profiles) assert(not ok, "an unknown agent stops the workflow") has(tostring(err), "unknown agent 'nobody'") - - has(toml_workflows.handle({ - prompt = "x", - steps = { { id = "one", agent = "nobody", prompt = "Do it." } }, - }, profiles), "Error:") assert(#handle.spawns == 0) end) end }, - { "the tool takes exactly one of name and steps", function() + { "the tool requires both a name and a prompt", function() with_host(function(handle, profiles) - has(toml_workflows.handle({ prompt = "x" }, profiles), "pass exactly one of `name`") - has(toml_workflows.handle({ prompt = "x", name = "a", steps = {} }, profiles), "not both") + has(toml_workflows.handle({ prompt = "x" }, profiles), "`name` is required") has(toml_workflows.handle({ name = "a" }, profiles), "prompt is required") assert(#handle.spawns == 0) end) end }, - { "the tool runs a transient definition and presents each child prompt only when expanded", function() - with_host(function(handle, profiles) - handle.queue_for("alpha", { output = "transient output" }) - progress.reset() - local component - progress.claim({ - id = "workflow-call", - tool_name = "subagents.workflow", - collapsed = true, - set_component = function(_, value) - component = value - return { - invalidate = function() end, - alive = function() return true end, - set_pinned = function() end, - } - end, - }) - progress.bind({ tool_call_id = "workflow-call" }) - local text = toml_workflows.handle({ - prompt = "the input", - steps = { { id = "only", agent = "alpha", prompt = "Do it." } }, - }, profiles) - has(text, "step: only") - has(text, "transient output") - has(handle.spawns[1].prompt, "Do it.\n\n## Workflow input\n\nthe input") - local compact = table.concat(component:render(100), "\n") - assert(not compact:find("Do it.", 1, true), compact) - assert(not compact:find("the input", 1, true), compact) - progress.collapse({ collapsed = false }) - local expanded = table.concat(component:render(100), "\n") - has(expanded, "prompt: Do it.") - has(expanded, "## Workflow input") - has(expanded, "the input") - assert(not expanded:find("system prompt:", 1, true), expanded) - - has(toml_workflows.handle({ - prompt = "x", - steps = { { id = "only", agent = "alpha", prompt = "p", needs = { "ghost" } } }, - }, profiles), "unknown dependency 'ghost'") - progress.reset() + { "the tool runs a named workflow and presents each child prompt only when expanded", function() + return with_layers({ + ["project/workflows/solo.toml"] = table.concat({ + 'name = "solo"', + '[[steps]]', + 'id = "only"', + 'agent = "alpha"', + 'prompt = "Do it."', + }, "\n"), + }, function() + with_host(function(handle, profiles) + toml_workflows.discover_and_register(profiles) + handle.queue_for("alpha", { output = "named output" }) + progress.reset() + local component + progress.claim({ + id = "workflow-call", + tool_name = "subagents.workflow", + collapsed = true, + set_component = function(_, value) + component = value + return { + invalidate = function() end, + alive = function() return true end, + set_pinned = function() end, + } + end, + }) + progress.bind({ tool_call_id = "workflow-call" }) + local text = toml_workflows.handle({ name = "solo", prompt = "the input" }, profiles) + has(text, "step: only") + has(text, "named output") + has(handle.spawns[1].prompt, "Do it.\n\n## Workflow input\n\nthe input") + local compact = table.concat(component:render(100), "\n") + assert(not compact:find("Do it.", 1, true), compact) + assert(not compact:find("the input", 1, true), compact) + progress.collapse({ collapsed = false }) + local expanded = table.concat(component:render(100), "\n") + has(expanded, "prompt: Do it.") + has(expanded, "## Workflow input") + has(expanded, "the input") + assert(not expanded:find("system prompt:", 1, true), expanded) + progress.reset() + end) end) end }, } -- cgit v1.3