summaryrefslogtreecommitdiff
path: root/spec
diff options
context:
space:
mode:
authort <t@tjp.lol>2026-08-19 19:02:30 -0600
committert <t@tjp.lol>2026-08-19 19:03:52 -0600
commitd1306506aa7f504b0e91c9c6ed7314afbf99978e (patch)
treee5a7295dbc566a3679e99e14dae13376b0469e84 /spec
parent94e3fd8358bbdb5d6ed81aed475fab7fc73e2097 (diff)
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.
Diffstat (limited to 'spec')
-rw-r--r--spec/fake_ext.lua33
-rw-r--r--spec/test_init.lua121
-rw-r--r--spec/test_jobs.lua36
-rw-r--r--spec/test_luatool.lua330
-rw-r--r--spec/test_models.lua49
-rw-r--r--spec/test_profiles.lua16
-rw-r--r--spec/test_progress_replay.lua23
-rw-r--r--spec/test_run.lua42
-rw-r--r--spec/test_toml_workflows.lua161
-rw-r--r--spec/test_workflow.lua41
10 files changed, 520 insertions, 332 deletions
diff --git a/spec/fake_ext.lua b/spec/fake_ext.lua
index 5207e03..88c4b56 100644
--- a/spec/fake_ext.lua
+++ b/spec/fake_ext.lua
@@ -334,6 +334,7 @@ local function new_job(cfg)
_events = cfg.events or {},
_finish = cfg.finish,
_harness = cfg.harness,
+ _wake_fd = cfg.wake_fd,
_polls = 0,
_settled = false,
_result = nil,
@@ -372,6 +373,7 @@ function job_mt:result()
elseif self._polls >= self._settle_after then
self._result = self._finish(self)
else
+ if self._wake_fd then pcall(require("luv").fs_write, self._wake_fd, "x", -1) end
return nil
end
self._settled = true
@@ -488,6 +490,17 @@ function agent_mt:conversation()
return self._conv
end
+function agent_mt:add_system_message(text)
+ return self._conv:add_system_message(text)
+end
+
+function agent_mt:submit(value)
+ if not self._borrowed then
+ error("panto: submit is only available on the host session agent", 2)
+ end
+ self._harness.submissions[#self._harness.submissions + 1] = value
+end
+
function agent_mt:set_message_metadata(index, metadata)
self._record.final_metadata = metadata
return true
@@ -552,6 +565,7 @@ function agent_mt:run_async(options)
settle = outcome_for(h, record).settle,
events = outcome_for(h, record).events,
harness = h,
+ wake_fd = options.wake_fd,
finish = function()
return settled_result(h, record)
end,
@@ -559,6 +573,7 @@ function agent_mt:run_async(options)
h.jobs[#h.jobs + 1] = job
record.job = job
h.live = h.live + 1
+ if options.wake_fd then pcall(require("luv").fs_write, options.wake_fd, "x", -1) end
if h.live > h.max_live then
h.max_live = h.live
end
@@ -609,6 +624,8 @@ function M.install(opts)
commands_by_name = {},
models_queries = {},
subscriptions = {},
+ submissions = {},
+ emitted = {},
on_by_name = {},
mkdirs = made_dirs,
queued = {},
@@ -653,6 +670,18 @@ function M.install(opts)
local ext = {}
+ -- The host's layer list, in the same shape and order panto installs it.
+ ext.dirs = opts.dirs or {
+ home = "/home/u",
+ project_root = "/proj",
+ layers = {
+ { name = "base", dir = "/data/panto/agent" },
+ { name = "user", dir = "/home/u/.config/panto" },
+ { name = "project", dir = "/proj/.panto" },
+ { name = "local", dir = "/proj/.panto/local" },
+ },
+ }
+
function ext.session_info()
return copy(handle.session)
end
@@ -697,6 +726,10 @@ function M.install(opts)
handle.on_by_name[name] = fn
end
+ function ext.emit(name)
+ handle.emitted[#handle.emitted + 1] = name
+ end
+
-- Fire a subscribed lifecycle handler, the way the host would.
function handle.emit(name, event)
for _, subscription in ipairs(handle.subscriptions) do
diff --git a/spec/test_init.lua b/spec/test_init.lua
index 97f0eba..5d2f8d9 100644
--- a/spec/test_init.lua
+++ b/spec/test_init.lua
@@ -11,6 +11,8 @@
local fake = require("spec.fake_ext")
local jobs = require("subagents.jobs")
local paths = require("subagents.paths")
+local workflow = require("subagents.workflow")
+local uv = require("luv")
local entry = require("init")
@@ -105,16 +107,24 @@ return {
has(run_tool.description, "one tool batch")
assert(run_tool.schema.required[1] == "prompt", "prompt is the only required field")
assert(run_tool.schema.properties.agent and run_tool.schema.properties.id)
+ assert(run_tool.schema.properties.system_prompt, "run accepts an inline system prompt")
+ has(run_tool.schema.properties.model.description, "subagents.models")
assert(type(run_tool.handler) == "function")
assert(handle.tools_by_name["subagents.lua"].schema.properties.source, "the lua tool takes source")
+ assert(handle.tools_by_name["subagents.lua"].schema.properties.prompt == nil,
+ "workflow prompts live inside source")
+ assert(#handle.tools_by_name["subagents.lua"].schema.required == 1
+ and handle.tools_by_name["subagents.lua"].schema.required[1] == "source")
local inline_agents = handle.tools_by_name["subagents.lua"].schema.properties.agents
assert(inline_agents and inline_agents.items.required,
"the lua tool describes workflow-local agent profiles")
assert(inline_agents.items.properties.system_prompt,
"an inline profile carries its system prompt")
- assert(handle.tools_by_name["subagents.workflow"].schema.properties.steps.items.required,
- "the workflow tool describes its step shape")
+ assert(handle.tools_by_name["subagents.workflow"].schema.properties.steps == nil,
+ "inline workflow definitions belong to subagents.lua")
+ assert(handle.tools_by_name["subagents.workflow"].schema.properties.name,
+ "the workflow tool runs a discovered workflow by name")
assert(type(header) == "table" and type(header.render) == "function",
"activation wraps the session header")
@@ -130,6 +140,59 @@ return {
"the header inventory matches command registration")
assert(rendered[#rendered] == "", "annotations stay before the trailing blank")
assert(#rendered > 4, "inventories wrap at the component width")
+
+ local primary_messages = handle.ext.agent:conversation():messages()
+ local guidance = primary_messages[#primary_messages].blocks[1].text
+ has(guidance, "## Subagents")
+ has(guidance, "subagents.workflows")
+ has(guidance, "name=\"implement\"")
+ has(guidance, "Review correctness")
+ end },
+
+ { "layered config sets max_concurrent with later layers winning", function()
+ local tmp = os.tmpname()
+ os.remove(tmp)
+ assert(os.execute("mkdir -p " .. tmp .. "/base " .. tmp .. "/project"))
+ local file = assert(io.open(tmp .. "/base/config.toml", "w"))
+ file:write("[other]\nmax_concurrent = 99\n\n[subagents]\nmax_concurrent = 6 # comment\n")
+ file:close()
+ file = assert(io.open(tmp .. "/project/config.toml", "w"))
+ file:write("[subagents]\nmax_concurrent = 8\n")
+ file:close()
+
+ activate_bare(function(_, ok, err)
+ os.execute("rm -rf " .. tmp)
+ assert(ok, tostring(err))
+ assert(jobs.MAX_CONCURRENT == 8, tostring(jobs.MAX_CONCURRENT))
+ jobs.MAX_CONCURRENT = 5
+ end, {
+ before = function(handle)
+ handle.ext.dirs = { layers = {
+ { name = "base", dir = tmp .. "/base" },
+ { name = "project", dir = tmp .. "/project" },
+ } }
+ end,
+ })
+ end },
+
+ { "max_concurrent must be a positive integer", function()
+ local tmp = os.tmpname()
+ os.remove(tmp)
+ assert(os.execute("mkdir -p " .. tmp))
+ local file = assert(io.open(tmp .. "/config.toml", "w"))
+ file:write("[subagents]\nmax_concurrent = 0\n")
+ file:close()
+
+ activate_bare(function(_, ok, err)
+ os.execute("rm -rf " .. tmp)
+ assert(not ok, "invalid concurrency must fail activation")
+ has(tostring(err), "must be a positive integer")
+ jobs.MAX_CONCURRENT = 5
+ end, {
+ before = function(handle)
+ handle.ext.dirs = { layers = { { name = "project", dir = tmp } } }
+ end,
+ })
end },
{ "an interrupted turn cancels every live child, and its end closes them", function()
@@ -140,7 +203,9 @@ return {
assert(type(handle.on_by_name["turn_start"]) == "function",
"startup replay must end before the first live turn")
assert(type(handle.on_by_name["turn_end"]) == "function",
- "a finished turn must be able to close its children")
+ "a finished turn must reap settled children")
+ assert(type(handle.on_by_name["session_end"]) == "function",
+ "session teardown must cancel background workflows")
-- A child that would not settle on its own, so the lifecycle is the
-- only thing that can end it. close_all runs whatever happens, or a
@@ -155,16 +220,58 @@ return {
assert(job._cancel_requested, "an interrupted turn asks its children to stop")
handle.emit("turn_end", { phase = "end", reason = "interrupted" })
assert(not job._closed, "the end of the turn never joins a pump from the owner thread")
- -- The pump exits (the fake settles cancelled on its next poll)
- -- and the drain that notices it does the close.
+ -- The pump exits (the fake settles cancelled on its next poll).
+ -- Ordinary turn_end only reaps settled jobs so background work
+ -- can survive; the next boundary closes this settled handle.
jobs.await({ started }, "all")
- assert(job._closed, "a child closes as soon as its pump exits")
+ assert(not job._closed, "settlement alone does not mutate the live catalog")
+ handle.emit("turn_end", { phase = "end", reason = "completed" })
+ assert(job._closed, "the next turn boundary reaps the settled child")
end)
jobs.close_all()
assert(checked, failure)
end)
end },
+ { "background workflows survive turn_end and interruption cancels them", function()
+ activate_bare(function(handle, ok, err)
+ assert(ok, tostring(err))
+ local tool = handle.tools_by_name["subagents.lua"]
+ handle.queue({ output = "finished", settle = 3 })
+ local id = tool.handler({ source = [[
+ return subagents.workflow(function(ctx)
+ local result = ctx:agent{name="work", system_prompt="Work.", prompt="go"}:await()
+ return result.output
+ end)
+ ]] }, { tool_call_id = "background-call" })
+ handle.emit("turn_end", { reason = "completed" })
+ assert(workflow.workflows[id].status == "running")
+ for _ = 1, 100 do
+ uv.run("nowait")
+ if workflow.workflows[id].status ~= "running" then break end
+ end
+ assert(workflow.workflows[id].status == "completed", tostring(workflow.workflows[id].error))
+ assert(handle.submissions[#handle.submissions]:find(id, 1, true))
+
+ handle.queue({ output = "too late", settle = 99 })
+ local cancelled = tool.handler({ source = [[
+ return subagents.workflow(function(ctx)
+ local result = ctx:agent{name="slow", system_prompt="Work.", prompt="go"}:await()
+ return result.output
+ end)
+ ]] }, { tool_call_id = "cancel-call" })
+ uv.run("nowait")
+ local submissions = #handle.submissions
+ handle.emit("turn_interrupt", { reason = "interrupted" })
+ for _ = 1, 100 do
+ uv.run("nowait")
+ if workflow.workflows[cancelled].status ~= "running" then break end
+ end
+ assert(workflow.workflows[cancelled].status == "cancelled")
+ assert(#handle.submissions == submissions, "cancelled workflows do not wake the primary")
+ end)
+ end },
+
{ "only the subagents tool calls claim a progress component", function()
activate_bare(function(handle, ok, err)
assert(ok, tostring(err))
@@ -206,7 +313,7 @@ return {
"the entry is given a component that renders the cards")
assert(pins[1] == true, "the live progress component pins after claim")
local output = handle.tools_by_name["subagents.workflow"].handler(
- { prompt = "", steps = {} }, { tool_call_id = "call-1" })
+ { prompt = "", name = "" }, { tool_call_id = "call-1" })
assert(type(output) == "string", "the workflow handler returned its result")
assert(pins[2] == false, "handler completion unpins before the next model action")
handle.emit("tool_result", { id = "call-1", tool_name = "subagents.workflow" })
diff --git a/spec/test_jobs.lua b/spec/test_jobs.lua
index de36ab5..25faf6e 100644
--- a/spec/test_jobs.lua
+++ b/spec/test_jobs.lua
@@ -129,20 +129,20 @@ return {
end)
end },
- { "the gate runs four at a time and queues the rest", function()
+ { "the gate runs five at a time and queues the rest", function()
with_jobs(function()
local start, built = starter()
local handles = {}
- for _, name in ipairs({ "a", "b", "c", "d", "e", "f" }) do
+ for _, name in ipairs({ "a", "b", "c", "d", "e", "f", "g" }) do
handles[#handles + 1] = assert(start(name))
end
- assert(#built == 4, "the gate holds at four running, saw " .. #built)
- assert(handles[5]:result() == nil, "a queued child has not settled")
+ assert(#built == 5, "the gate holds at five running, saw " .. #built)
+ assert(handles[6]:result() == nil, "a queued child has not settled")
local results = jobs.await(handles, "all")
- assert(#built == 6, "the queue drains as slots free up, saw " .. #built)
- assert(#results == 6, "every child reports")
- assert(results[5].text == "e" and results[6].text == "f", "queued children keep their place")
+ assert(#built == 7, "the queue drains as slots free up, saw " .. #built)
+ assert(#results == 7, "every child reports")
+ assert(results[6].text == "f" and results[7].text == "g", "queued children keep their place")
end)
end },
@@ -150,20 +150,20 @@ return {
with_jobs(function()
local start, built = starter()
local handles = {}
- for _, name in ipairs({ "a", "b", "c", "d", "e" }) do
+ for _, name in ipairs({ "a", "b", "c", "d", "e", "f" }) do
handles[#handles + 1] = assert(start(name))
end
- handles[5]:cancel()
+ handles[6]:cancel()
- local result = handles[5]:result()
+ local result = handles[6]:result()
assert(type(result) == "table", "a cancelled queued child settles immediately")
assert(result.status == "cancelled", tostring(result.status))
assert(result.error == "cancelled before the child started", tostring(result.error))
- assert(#built == 4, "the queued child was never built")
- assert(not contains(built, "e"), "the queued child was never built")
+ assert(#built == 5, "the queued child was never built")
+ assert(not contains(built, "f"), "the queued child was never built")
- jobs.await({ handles[1], handles[2], handles[3], handles[4] }, "all")
- assert(#built == 4, "a cancelled child does not start when a slot frees up")
+ jobs.await({ handles[1], handles[2], handles[3], handles[4], handles[5] }, "all")
+ assert(#built == 5, "a cancelled child does not start when a slot frees up")
end)
end },
@@ -202,16 +202,16 @@ return {
with_jobs(function()
local start, built, made = starter()
local handles = {}
- for _, name in ipairs({ "a", "b", "c", "d", "e" }) do
+ for _, name in ipairs({ "a", "b", "c", "d", "e", "f" }) do
handles[#handles + 1] = assert(start(name, { settle = 5 }))
end
jobs.cancel_all()
- for _, name in ipairs({ "a", "b", "c", "d" }) do
+ for _, name in ipairs({ "a", "b", "c", "d", "e" }) do
assert(made[name]._cancel_requested, "running child " .. name .. " was not cancelled")
end
- assert(#built == 4, "cancel_all must not start the queued child")
- assert(handles[5]:result().status == "cancelled", "the queued child settles cancelled")
+ assert(#built == 5, "cancel_all must not start the queued child")
+ assert(handles[6]:result().status == "cancelled", "the queued child settles cancelled")
local results = jobs.await(handles, "all")
for index, result in ipairs(results) do
diff --git a/spec/test_luatool.lua b/spec/test_luatool.lua
index 65723db..d002bca 100644
--- a/spec/test_luatool.lua
+++ b/spec/test_luatool.lua
@@ -1,17 +1,11 @@
--- subagents/luatool.lua: the restricted environment, the instruction budget,
--- and one real fan-out through the fake host.
---
--- The sandbox cases check the environment the guest actually gets rather than
--- only the errors an escape attempt produces, because a missing global is the
--- whole mechanism. One documented gap is asserted as a gap: the real string
--- metatable is reachable from any literal, so `("").dump` exists. Without
--- `load` there is no way to run bytecode, so it stays noise rather than an
--- escape — the assertion is here so a future change to that reasoning is
--- deliberate.
+-- subagents/luatool.lua: sandboxing plus the session-scoped asynchronous
+-- workflow API exposed to model-authored Lua.
local fake = require("spec.fake_ext")
+local jobs = require("subagents.jobs")
local luatool = require("subagents.luatool")
-local progress = require("subagents.progress")
+local workflow = require("subagents.workflow")
+local uv = require("luv")
local function has(text, needle)
assert(type(text) == "string", "expected a string, got " .. type(text))
@@ -28,13 +22,22 @@ local function profile_set()
return set
end
+local function pump(id)
+ for _ = 1, 2000 do
+ uv.run("nowait")
+ local record = workflow.workflows[id]
+ if record and record.status ~= "running" then return record end
+ uv.sleep(1)
+ end
+ error("workflow did not settle: " .. tostring(id), 0)
+end
+
local function with_host(fn)
local handle = fake.install()
local ok, err = pcall(fn, handle, profile_set())
+ pcall(jobs.close_all)
handle.restore()
- if not ok then
- error(err, 0)
- end
+ if not ok then error(err, 0) end
end
return {
@@ -47,211 +50,190 @@ return {
}) do
assert(env[name] == nil, "the guest can reach " .. name)
end
- assert(env._G == env, "_G must point at the restricted table")
- assert(type(env.subagents.workflow) == "function", "the workflow constructor is the whole API")
- assert(env.string.dump == nil, "string.dump is removed from the guest copy")
- assert(env.string ~= string, "the guest gets a copy it may safely mutate")
- assert(env.print() == nil, "print is a no-op")
+ assert(env._G == env)
+ assert(type(env.subagents.workflow) == "function")
+ assert(type(env.subagents.workflows) == "table")
+ assert(env.string.dump == nil and env.string ~= string)
+ assert(env.print() == nil)
end },
- { "the string metatable stays reachable, and stays harmless", function()
+ { "the string metatable stays reachable and harmless", function()
local env = luatool.build_env()
- -- Documented gap: ("").dump resolves through the real string metatable.
- assert(type(("").dump) == "function", "the gap this note describes has moved")
- assert(env.load == nil and env.loadstring == nil,
- "bytecode is only dangerous with a loader, and there is none")
+ assert(type(("").dump) == "function")
+ assert(env.load == nil and env.loadstring == nil)
end },
- { "an escape attempt inside the guest fails at the call", function()
- with_host(function(handle, profiles)
- local source = [[
- return subagents.workflow(function(ctx, input)
- return { status = "completed", output = require("os").time() }
- end)
- ]]
- local text = luatool.handle({ prompt = "x", source = source }, profiles)
- has(text, "Error:")
- has(text, "nil value")
- assert(#handle.spawns == 0, "the guest started no children")
+ { "source is required and ordinary source values return directly", function()
+ with_host(function(_, profiles)
+ assert(luatool.handle({ source = "return 42" }, profiles) == "42")
+ has(luatool.handle({}, profiles), "Error: source is required")
+ has(luatool.handle({ source = "return (" }, profiles), "Error: source did not compile")
+ has(luatool.handle({ source = "error('nope')" }, profiles), "Error: source failed to run")
end)
end },
- { "source that does not return a workflow is refused", function()
+ { "a workflow returns an id immediately then records named results", function()
with_host(function(handle, profiles)
- has(luatool.handle({ prompt = "x", source = "return 42" }, profiles),
- "Error: source must return subagents.workflow(function(ctx, input) ... end)")
- has(luatool.handle({ prompt = "x", source = "return (" }, profiles),
- "Error: source did not compile")
- has(luatool.handle({ prompt = "x", source = "error('nope')" }, profiles),
- "Error: source failed to run")
- end)
- end },
+ handle.queue_for("alpha", { id = "0198-a", output = "alpha output" })
+ handle.queue_for("beta", { id = "0198-b", output = "beta output" })
+ local id = luatool.handle({ source = [[
+ return subagents.workflow(function(ctx)
+ local a = ctx:agent{name="research", agent="alpha", prompt="research"}
+ local b = ctx:agent{name="review", agent="beta", prompt="review"}
+ local results = ctx:await({a, b}, "all")
+ return results[1].output .. " + " .. results[2].output
+ end)
+ ]] }, profiles, { tool_call_id = "lua-call" })
- { "prompt and source are both required", function()
- with_host(function(handle, profiles)
- has(luatool.handle({ source = "return 1" }, profiles), "Error: prompt is required")
- has(luatool.handle({ prompt = "x" }, profiles), "Error: source is required")
- has(luatool.handle({ prompt = "", source = "return 1" }, profiles), "Error: prompt is required")
+ has(id, "workflow-")
+ assert(workflow.workflows[id].status == "running")
+ assert(#handle.spawns == 0, "the callback starts after the tool returns")
+
+ local record = pump(id)
+ assert(record.status == "completed", tostring(record.error))
+ assert(record.result == "alpha output + beta output", tostring(record.result))
+ assert(#record.agents == 2)
+ assert(record.agents.research.output == "alpha output")
+ assert(record.agents[2].name == "review")
+ assert(record.agents.review.status == "completed")
+ assert(#handle.submissions == 1, "completion wakes the primary once")
+ has(handle.submissions[1], id)
+ assert(handle.emitted[1] == "agent_submission", "the host pipeline is explicitly woken")
end)
end },
- { "a runaway guest is stopped by the instruction budget", function()
- with_host(function(handle, profiles)
- local source = [[
- return subagents.workflow(function(ctx, input)
- local n = 0
- while true do n = n + 1 end
- end)
- ]]
- local text = luatool.handle({ prompt = "x", source = source }, profiles)
- has(text, "Error:")
- has(text, "instruction budget exceeded")
+ { "a later Lua call inspects workflows and records are read-only", function()
+ with_host(function(_, profiles)
+ local id = luatool.handle({ source = [[
+ return subagents.workflow(function(ctx) return "done" end)
+ ]] }, profiles)
+ pump(id)
+ local query = string.format(
+ "local w=subagents.workflows[%q]; return w.status .. '|' .. w.result", id)
+ assert(luatool.handle({ source = query }, profiles) == "completed|done")
+
+ local mutation = string.format(
+ "subagents.workflows[%q].status='forged'; return 'bad'", id)
+ has(luatool.handle({ source = mutation }, profiles), "read-only")
+ assert(workflow.workflows[id].status == "completed")
end)
end },
- { "the guest cannot catch the budget error and spin again", function()
+ { "completed agent output is inspectable while its workflow still runs", function()
with_host(function(handle, profiles)
- -- Bounded so a regression fails this case instead of hanging it: with
- -- pcall back in the environment the guest would burn three budgets and
- -- then report success.
- local source = [[
- return subagents.workflow(function(ctx, input)
- for _ = 1, 3 do
- pcall(function() while true do end end)
- end
- return { status = "completed", output = "outlived the budget" }
+ handle.queue_for("alpha", { output = "early" })
+ handle.queue_for("beta", { output = "late", settle = 100000 })
+ local id = luatool.handle({ source = [[
+ return subagents.workflow(function(ctx)
+ local first = ctx:agent{name="first", agent="alpha", prompt="first"}:await()
+ local second = ctx:agent{name="second", agent="beta", prompt="second"}:await()
+ return first.output .. second.output
end)
- ]]
- local text = luatool.handle({ prompt = "x", source = source }, profiles)
- has(text, "Error:")
- has(text, "pcall")
+ ]] }, profiles)
+ for _ = 1, 100 do
+ uv.run("nowait")
+ local w = workflow.workflows[id]
+ if w.agents.first and w.agents.first.status == "completed" and w.agents.second then break end
+ end
+ local w = workflow.workflows[id]
+ assert(w.status == "running")
+ assert(w.agents.first.output == "early")
+ assert(w.agents.second.status == "running")
+ local query = string.format(
+ "local w=subagents.workflows[%q]; return w.agents.first.output .. '|' .. w.status", id)
+ assert(luatool.handle({ source = query }, profiles) == "early|running")
+
+ local iterated = {}
+ for name, agent in pairs(w.agents) do iterated[#iterated + 1] = name .. ":" .. agent.status end
+ assert(iterated[1] == "first:completed" and iterated[2] == "second:running")
+ workflow.cancel_all(true)
+ jobs.cancel_all()
+ pump(id)
end)
end },
- { "inline agent profiles are scoped to one workflow invocation", function()
+ { "agent names are required and unique within a workflow", function()
with_host(function(handle, profiles)
- handle.queue_for("local-reviewer", { id = "0198-local", output = "reviewed" })
- local source = [[
- return subagents.workflow(function(ctx, input)
- return ctx:agent({ agent = "local-reviewer", prompt = input }):await()
+ local id = luatool.handle({ source = [[
+ return subagents.workflow(function(ctx)
+ ctx:agent{name="same", agent="alpha", prompt="one"}
+ ctx:agent{name="same", agent="beta", prompt="two"}
+ return "unreachable"
end)
- ]]
- progress.reset()
- local component
- progress.claim({
- id = "lua-call",
- tool_name = "subagents.lua",
- 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 = "lua-call" })
- local text = luatool.handle({
- prompt = "inspect this",
- source = source,
- agents = {
- {
- name = "local-reviewer",
- description = "One-off reviewer",
- system_prompt = "Review only the requested change.",
- },
- },
- }, profiles)
+ ]] }, profiles)
+ local record = pump(id)
+ assert(record.status == "failed")
+ has(record.error, "duplicate workflow agent name 'same'")
+ assert(#handle.spawns == 1, "the duplicate is rejected before spawning")
- has(text, "reviewed")
- local compact = table.concat(component:render(100), "\n")
- assert(not compact:find("inspect this", 1, true), compact)
- assert(not compact:find("Review only the requested change.", 1, true), compact)
- progress.collapse({ collapsed = false })
- local expanded = table.concat(component:render(100), "\n")
- has(expanded, "system prompt: Review only the requested change.")
- has(expanded, "prompt: inspect this")
- assert(#handle.spawns == 1, "the inline profile started one child")
- assert(handle.spawns[1].label == "local-reviewer")
- local seeded = handle.spawns[1].system_messages
- assert(seeded[#seeded].text == "Review only the requested change.")
-
- local missing = luatool.handle({ prompt = "again", source = source }, profiles)
- has(missing, "unknown agent 'local-reviewer'")
- assert(#handle.spawns == 1, "the inline profile did not leak into the next workflow")
- progress.reset()
+ local missing = luatool.handle({ source = [[
+ return subagents.workflow(function(ctx)
+ ctx:agent{agent="alpha", prompt="one"}
+ return "unreachable"
+ end)
+ ]] }, profiles)
+ has(pump(missing).error, "requires a non-empty unique `name`")
end)
end },
- { "invalid inline profiles fail before running guest source", function()
- with_host(function(handle, profiles)
- local text = luatool.handle({
- prompt = "x",
- source = "error('guest source should not run')",
- agents = { { name = "local", system_prompt = "" } },
- }, profiles)
- has(text, "agents[1].system_prompt must be a non-empty string")
- assert(#handle.spawns == 0)
+ { "callback errors and non-string returns fail the workflow", function()
+ with_host(function(_, profiles)
+ local bad_type = luatool.handle({ source = [[
+ return subagents.workflow(function(ctx) return {"no"} end)
+ ]] }, profiles)
+ local record = pump(bad_type)
+ assert(record.status == "failed")
+ has(record.error, "must return a string")
+
+ local raised = luatool.handle({ source = [[
+ return subagents.workflow(function(ctx) error("boom") end)
+ ]] }, profiles)
+ has(pump(raised).error, "boom")
end)
end },
- { "a fan-out runs end to end and renders one block per child", function()
+ { "inline profiles are scoped to workflows started by one call", function()
with_host(function(handle, profiles)
- handle.queue_for("alpha", { id = "0198-a", output = "alpha says hi" })
- handle.queue_for("beta", { id = "0198-b", output = "beta says hi" })
-
+ handle.queue_for("local", { output = "local output" })
local source = [[
- return subagents.workflow(function(ctx, input)
- local jobs = {}
- for _, name in ipairs({ "alpha", "beta" }) do
- jobs[#jobs + 1] = ctx:agent({ agent = name, prompt = "handle " .. input })
- end
- return ctx:await(jobs, "all")
+ return subagents.workflow(function(ctx)
+ local result = ctx:agent{name="work", agent="local", prompt="inspect"}:await()
+ return result.output
end)
]]
- local text = luatool.handle({ prompt = "the task", source = source }, profiles)
+ local id = luatool.handle({
+ source = source,
+ agents = { { name = "local", system_prompt = "Be local." } },
+ }, profiles)
+ assert(pump(id).result == "local output")
+ assert(handle.spawns[1].system_messages[2].text == "Be local.")
- assert(#handle.spawns == 2, "one spawn per ctx:agent")
- assert(handle.spawns[1].prompt == "handle the task", tostring(handle.spawns[1].prompt))
- assert(handle.spawns[1].label == "alpha")
- has(text, "id: 0198-a")
- has(text, "alpha says hi")
- has(text, "id: 0198-b")
- has(text, "beta says hi")
- assert(select(2, text:gsub("status: completed", "")) == 2, "expected two rendered blocks")
+ local missing = luatool.handle({ source = source }, profiles)
+ has(pump(missing).error, "unknown agent 'local'")
+ assert(#handle.spawns == 1)
end)
end },
- { "the job budget applies to a generated workflow", function()
+ { "the instruction and job budgets apply to background workflows", function()
with_host(function(handle, profiles)
- local source = string.format([[
- return subagents.workflow(function(ctx, input)
- for index = 1, %d do
- ctx:agent({ agent = "alpha", prompt = "spam " .. index })
- end
+ local runaway = luatool.handle({ source = [[
+ return subagents.workflow(function(ctx)
+ local n=0; while true do n=n+1 end
end)
- ]], luatool.max_jobs + 1)
- local text = luatool.handle({ prompt = "x", source = source }, profiles)
- has(text, "job limit exceeded")
- assert(#handle.runs == luatool.max_jobs, "the cap is enforced at the host boundary")
- end)
- end },
+ ]] }, profiles)
+ has(pump(runaway).error, "instruction budget exceeded")
- { "the guest cannot raise its own job cap through ctx", function()
- with_host(function(handle, profiles)
local source = string.format([[
- return subagents.workflow(function(ctx, input)
- ctx.max_jobs = nil
- ctx.job_count = 0
- for index = 1, %d do
- ctx:agent({ agent = "alpha", prompt = "spam " .. index })
+ return subagents.workflow(function(ctx)
+ for index=1,%d do
+ ctx:agent{name="job-"..index, agent="alpha", prompt="spam"}
end
+ return "unreachable"
end)
]], luatool.max_jobs + 1)
- local text = luatool.handle({ prompt = "x", source = source }, profiles)
- has(text, "job limit exceeded")
- assert(#handle.runs == luatool.max_jobs, "the cap is private state, not a ctx field")
+ local capped = luatool.handle({ source = source }, profiles)
+ has(pump(capped).error, "job limit exceeded")
+ assert(#handle.runs == luatool.max_jobs)
end)
end },
}
diff --git a/spec/test_models.lua b/spec/test_models.lua
index ae01396..56d0877 100644
--- a/spec/test_models.lua
+++ b/spec/test_models.lua
@@ -1,4 +1,4 @@
--- subagents/models.lua: the four catalog query forms and the profile join.
+-- subagents/models.lua: the three catalog query forms.
--
-- The fake host answers by query shape, so each case checks both what the tool
-- asked the host for (`handle.models_queries`) and how it rendered the answer.
@@ -42,25 +42,9 @@ local function catalog(query)
}
end
-local function profile_set()
- local reviewer = {
- name = "reviewer",
- description = "Reviews changes",
- model = "anthropic:sonnet",
- reasoning = "high",
- body = "b",
- }
- local scout = { name = "scout", description = "", body = "b" }
- return {
- list = { reviewer, scout },
- by_name = { reviewer = reviewer, scout = scout },
- warnings = {},
- }
-end
-
local function with_host(fn)
local handle = fake.install({ models_response = catalog })
- local ok, err = pcall(fn, handle, profile_set())
+ local ok, err = pcall(fn, handle)
handle.restore()
if not ok then
error(err, 0)
@@ -119,35 +103,6 @@ return {
end)
end },
- { "an agent with a model reports that model", function()
- with_host(function(handle, profiles)
- local text = models.handle({ agent = "reviewer" }, profiles)
- has(text, "agent: reviewer")
- has(text, "description: Reviews changes")
- has(text, "wire model: claude-sonnet-4-6")
- has(text, "profile reasoning: high")
- assert(handle.models_queries[1].model == "anthropic:sonnet", "the profile model is looked up")
- end)
- end },
-
- { "an agent without a model says it inherits", function()
- with_host(function(handle, profiles)
- local text = models.handle({ agent = "scout" }, profiles)
- has(text, "agent: scout")
- has(text, "model: inherits the primary model")
- has(text, "inherited model: anthropic:sonnet")
- assert(next(handle.models_queries[1]) == nil, "the inherited case asks for the overview")
- end)
- end },
-
- { "an unknown agent names the known profiles", function()
- with_host(function(handle, profiles)
- local text = models.handle({ agent = "ghost" }, profiles)
- has(text, "Error: unknown agent 'ghost'")
- has(text, "reviewer, scout")
- end)
- end },
-
{ "a non-string field is refused", function()
with_host(function(handle, profiles)
has(models.handle({ query = 12 }, profiles), "Error: `query` must be a non-empty string")
diff --git a/spec/test_profiles.lua b/spec/test_profiles.lua
index 3a052b9..f2a54a2 100644
--- a/spec/test_profiles.lua
+++ b/spec/test_profiles.lua
@@ -124,6 +124,22 @@ return {
table.concat(found.warnings, " | "))
end },
+ { "config roots follow every host layer, in host order", function()
+ local fake = require("spec.fake_ext")
+ local paths = require("subagents.paths")
+ local handle = fake.install()
+ local ok, err = pcall(function()
+ local roots = paths.config_roots("workflows")
+ assert(#roots == 4, "expected one root per layer, saw " .. #roots)
+ assert(roots[1] == "/data/panto/agent/workflows", roots[1])
+ assert(roots[2] == "/home/u/.config/panto/workflows", roots[2])
+ assert(roots[3] == "/proj/.panto/workflows", roots[3])
+ assert(roots[4] == "/proj/.panto/local/workflows", roots[4])
+ end)
+ handle.restore()
+ if not ok then error(err, 0) end
+ end },
+
{ "the list is sorted by name", function()
local found, reason = fixture_or_skip()
if not found then
diff --git a/spec/test_progress_replay.lua b/spec/test_progress_replay.lua
index cf2925e..ffe7e5c 100644
--- a/spec/test_progress_replay.lua
+++ b/spec/test_progress_replay.lua
@@ -3,6 +3,8 @@ local progress = require("subagents.progress")
local run = require("subagents.run")
local luatool = require("subagents.luatool")
local toml_workflows = require("subagents.toml_workflows")
+local workflow = require("subagents.workflow")
+local uv = require("luv")
local function plain(lines)
return table.concat(lines, "\n"):gsub("\27%[[%d;]*m", "")
@@ -269,24 +271,29 @@ return {
"settled status is retained for durable presentation")
handle.queue_for("alpha", { output = "fixed done" })
- toml_workflows.handle({
- prompt = "fixed",
+ toml_workflows.run(assert(toml_workflows.validate({
+ name = "fixed",
steps = { { id = "step", agent = "alpha", prompt = "run fixed" } },
- }, profiles)
+ }, "fixed")), "fixed", profiles)
assert(handle.runs[2].metadata.subagents.tool_call_id == "outer-run",
"fixed workflow child keeps the same outer owner")
assert(handle.runs[2].metadata.subagents.sequence == 2,
"fixed workflow child follows spawn order")
handle.queue_for("inline", { output = "inline done" })
- local source = [[return subagents.workflow(function(ctx, input)
- return ctx:agent({ agent = "inline", prompt = input }):await()
+ local source = [[return subagents.workflow(function(ctx)
+ local result = ctx:agent({ name = "inline-work", agent = "inline", prompt = "dynamic" }):await()
+ return result.output
end)]]
- luatool.handle({
- prompt = "dynamic",
+ local workflow_id = luatool.handle({
source = source,
agents = { { name = "inline", system_prompt = "INLINE" } },
- }, profiles)
+ }, profiles, { tool_call_id = "outer-run" })
+ for _ = 1, 100 do
+ uv.run("nowait")
+ if workflow.workflows[workflow_id].status ~= "running" then break end
+ end
+ assert(workflow.workflows[workflow_id].status == "completed")
local child = handle.spawns[3]
local manifest = child.system_messages[2].metadata.subagents
assert(manifest.inline == true, "inline workflow manifest is explicitly marked")
diff --git a/spec/test_run.lua b/spec/test_run.lua
index 57c6e47..b2c5783 100644
--- a/spec/test_run.lua
+++ b/spec/test_run.lua
@@ -75,11 +75,15 @@ return {
end)
end },
- { "both agent and id is refused", function()
+ { "multiple selectors are refused", function()
with_host(function(handle, profiles)
local text = run.handle({ agent = "reviewer", id = "0198-x", prompt = "go" }, profiles)
has(text, "Error:")
- has(text, "not both")
+ has(text, "exactly one of `agent`")
+ assert(#handle.spawns == 0)
+
+ text = run.handle({ agent = "reviewer", system_prompt = "Be concise.", prompt = "go" }, profiles)
+ has(text, "exactly one of `agent`")
assert(#handle.spawns == 0)
end)
end },
@@ -124,6 +128,21 @@ return {
end)
end },
+ { "an inline system prompt starts a child without a profile", function()
+ with_host(function(handle, profiles)
+ local text = run.handle({ system_prompt = "Be a focused investigator.", prompt = "Find the cause." }, profiles)
+ assert(#handle.spawns == 1)
+ local messages = handle.spawns[1].system_messages
+ assert(#messages == 2, "expected role and inline prompt")
+ assert(messages[1].text == spawn.CHILD_ROLE)
+ assert(messages[2].text == "Be a focused investigator.")
+ local manifest = messages[2].metadata.subagents
+ assert(manifest.agent == "subagent", tostring(manifest.agent))
+ assert(manifest.inline == true, "inline prompts must remain identifiable on replay")
+ has(text, "agent: subagent")
+ end)
+ end },
+
{ "subagents.run presents its child prompt only when expanded", function()
with_host(function(handle, profiles)
handle.queue_for("reviewer", { events = {
@@ -171,22 +190,23 @@ return {
end)
end },
- { "the primary's system context comes first, then the role, then the profile", function()
+ { "the primary's system context and dialogue are not copied", function()
with_host(function(handle, profiles)
run.handle({ agent = "reviewer", prompt = "go" }, profiles)
local messages = handle.spawns[1].system_messages
- assert(#messages == 4, "expected two copied messages plus role and profile, saw " .. #messages)
- assert(messages[1].text == "Project context.", tostring(messages[1].text))
- assert(messages[2].text == "House style.", tostring(messages[2].text))
- assert(messages[3].text == spawn.CHILD_ROLE, "the child role follows the primary's context")
- assert(messages[4].text == "You are a reviewer.\n", "the profile body is last")
- assert(messages[1].metadata == nil, "copied context carries no manifest")
+ assert(#messages == 2, "expected only role and profile messages, saw " .. #messages)
+ assert(messages[1].text == spawn.CHILD_ROLE, "the child role is first")
+ assert(messages[2].text == "You are a reviewer.\n", "the profile defines the child context")
+ for _, message in ipairs(messages) do
+ assert(message.text ~= "Primary core prompt.", "the primary system prompt must not reach the child")
+ assert(message.text ~= "Project context.", "primary system additions must not be copied")
+ end
end, {
primary_messages = {
- { role = "system", text = "Project context." },
+ { role = "system", text = "Primary core prompt." },
{ role = "user", text = "the parent dialogue is never copied" },
{ role = "assistant", text = "nor this" },
- { role = "system", text = "House style." },
+ { role = "system", text = "Project context." },
},
})
end },
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 },
}
diff --git a/spec/test_workflow.lua b/spec/test_workflow.lua
index 5d58c01..2e66448 100644
--- a/spec/test_workflow.lua
+++ b/spec/test_workflow.lua
@@ -35,7 +35,7 @@ end
local function three_children(ctx)
local handles = {}
for index, name in ipairs({ "alpha", "beta", "gamma" }) do
- handles[index] = ctx:agent({ agent = name, prompt = "work on " .. name })
+ handles[index] = ctx:agent({ name = name, agent = name, prompt = "work on " .. name })
end
return handles
end
@@ -100,27 +100,27 @@ return {
with_host(function(handle, profiles)
handle.queue_for("alpha", { output = "just me" })
local result = workflow.execute(workflow.workflow(function(ctx)
- return ctx:agent({ agent = "alpha", prompt = "go" }):await()
+ return ctx:agent({ name = "only", agent = "alpha", prompt = "go" }):await()
end), "input", { profiles = profiles })
assert(result.status == "completed", tostring(result.status))
assert(result.output == "just me", tostring(result.output))
end)
end },
- { "the gate keeps four children in flight and queues the rest", function()
+ { "the gate keeps five children in flight and queues the rest", function()
with_host(function(handle, profiles)
local results = workflow.execute(workflow.workflow(function(ctx)
local handles = {}
- for index = 1, 5 do
- handles[index] = ctx:agent({ agent = "alpha", prompt = "task " .. index })
+ for index = 1, 6 do
+ handles[index] = ctx:agent({ name = "job-" .. index, agent = "alpha", prompt = "task " .. index })
end
- assert(#handle.runs == 4, "four turns run at once, saw " .. #handle.runs)
+ assert(#handle.runs == 5, "five turns run at once, saw " .. #handle.runs)
return ctx:await(handles, "all")
end), "input", { profiles = profiles })
- assert(#results == 5, "every child reports")
- assert(#handle.runs == 5, "the queued child starts once a slot frees up")
- assert(handle.max_live == 4, "never more than four at once, peaked at " .. handle.max_live)
+ assert(#results == 6, "every child reports")
+ assert(#handle.runs == 6, "the queued child starts once a slot frees up")
+ assert(handle.max_live == 5, "never more than five at once, peaked at " .. handle.max_live)
end)
end },
@@ -129,8 +129,8 @@ return {
handle.queue_for("beta", { output = "B" })
local results = workflow.execute(workflow.workflow(function(ctx)
- local first = ctx:agent({ agent = "alpha", model = "openai:ghost", prompt = "a" })
- local second = ctx:agent({ agent = "beta", prompt = "b" })
+ local first = ctx:agent({ name = "first", agent = "alpha", model = "openai:ghost", prompt = "a" })
+ local second = ctx:agent({ name = "second", agent = "beta", prompt = "b" })
return ctx:await({ first, second }, "all")
end), "input", { profiles = profiles })
@@ -146,7 +146,7 @@ return {
with_host(function(handle, profiles)
handle.queue_for("alpha", { status = "failed", error = "provider refused", resumable = false })
local result = workflow.execute(workflow.workflow(function(ctx)
- return ctx:agent({ agent = "alpha", prompt = "a" }):await()
+ return ctx:agent({ name = "failed", agent = "alpha", prompt = "a" }):await()
end), "input", { profiles = profiles })
assert(result.status == "failed")
assert(result.error == "provider refused", tostring(result.error))
@@ -162,8 +162,8 @@ return {
handle.queue({ output = "the first turn wins" })
local results = workflow.execute(workflow.workflow(function(ctx)
- local first = ctx:agent({ id = "0198-child", prompt = "a" })
- local second = ctx:agent({ id = "0198-child", prompt = "b" })
+ local first = ctx:agent({ name = "first", id = "0198-child", prompt = "a" })
+ local second = ctx:agent({ name = "second", id = "0198-child", prompt = "b" })
return ctx:await({ first, second }, "all")
end), "input", { profiles = profiles })
@@ -182,6 +182,7 @@ return {
handle.queue_for("alpha", { structured_json = '{"items":["x","y"]}' })
local result = workflow.execute(workflow.workflow(function(ctx)
return ctx:agent({
+ name = "worker",
agent = "alpha",
prompt = "split it",
output = { description = "Return the work items.", schema = ITEMS_SCHEMA },
@@ -219,6 +220,7 @@ return {
handle.queue_for("alpha", { structured_json = '{"nope":1}' })
local result = workflow.execute(workflow.workflow(function(ctx)
return ctx:agent({
+ name = "worker",
agent = "alpha",
prompt = "split it",
output = { schema = ITEMS_SCHEMA },
@@ -249,6 +251,7 @@ return {
handle.queue_for("alpha", { structured_json = '{"items":["x"]}' })
local result = workflow.execute(workflow.workflow(function(ctx)
return ctx:agent({
+ name = "worker",
agent = "alpha",
prompt = "split it",
output = { schema = ITEMS_SCHEMA },
@@ -268,6 +271,7 @@ return {
handle.queue_for("alpha", { structured_json = "" })
local result = workflow.execute(workflow.workflow(function(ctx)
return ctx:agent({
+ name = "worker",
agent = "alpha",
prompt = "split it",
output = { schema = ITEMS_SCHEMA },
@@ -283,6 +287,7 @@ return {
handle.queue_for("alpha", { output = "prose, not a tool call" })
local result = workflow.execute(workflow.workflow(function(ctx)
return ctx:agent({
+ name = "worker",
agent = "alpha",
prompt = "split it",
output = { schema = ITEMS_SCHEMA },
@@ -299,8 +304,8 @@ return {
handle.queue_for("gamma", { output = "C" })
local first_status = workflow.execute(workflow.workflow(function(ctx)
- local rejected = ctx:agent({ agent = "alpha", model = "openai:ghost", prompt = "a" })
- local live = { rejected, ctx:agent({ agent = "beta", prompt = "b" }) }
+ local rejected = ctx:agent({ name = "rejected", agent = "alpha", model = "openai:ghost", prompt = "a" })
+ local live = { rejected, ctx:agent({ name = "live", agent = "beta", prompt = "b" }) }
local result, remaining = ctx:await(live, "first")
assert(#remaining == 1, "the pending sibling stays outstanding")
assert(handle.polls == 0, "a cached result must not reach the job machinery")
@@ -326,7 +331,7 @@ return {
{ "handles the callback never awaited are settled before returning", function()
with_host(function(handle, profiles)
workflow.execute(workflow.workflow(function(ctx)
- ctx:agent({ agent = "alpha", prompt = "orphan" })
+ ctx:agent({ name = "orphan", agent = "alpha", prompt = "orphan" })
return "done"
end), "input", { profiles = profiles })
assert(#handle.jobs == 1, "one child ran")
@@ -337,7 +342,7 @@ return {
{ "an unknown agent inside a workflow is a workflow error", function()
with_host(function(handle, profiles)
local ok, err = pcall(workflow.execute, workflow.workflow(function(ctx)
- ctx:agent({ agent = "ghost", prompt = "go" })
+ ctx:agent({ name = "ghost", agent = "ghost", prompt = "go" })
end), "input", { profiles = profiles })
assert(not ok, "an unknown profile is a programmer error, not a child failure")
has(tostring(err), "unknown agent 'ghost'")