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/fake_ext.lua | 33 +++++ spec/test_init.lua | 121 ++++++++++++++- spec/test_jobs.lua | 36 ++--- spec/test_luatool.lua | 332 ++++++++++++++++++++---------------------- spec/test_models.lua | 49 +------ spec/test_profiles.lua | 16 ++ spec/test_progress_replay.lua | 23 ++- spec/test_run.lua | 42 ++++-- spec/test_toml_workflows.lua | 161 +++++++++++++------- spec/test_workflow.lua | 41 +++--- 10 files changed, 521 insertions(+), 333 deletions(-) (limited to 'spec') 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") + 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" }) + + 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 }, - { "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") + { "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 }, - { "a runaway guest is stopped by the instruction budget", function() + { "completed agent output is inspectable while its workflow still runs", function() with_host(function(handle, profiles) - local source = [[ - return subagents.workflow(function(ctx, input) - local n = 0 - while true do n = n + 1 end + 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, "instruction budget exceeded") + ]] }, 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 }, - { "the guest cannot catch the budget error and spin again", function() + { "agent names are required and unique within a workflow", 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" } + 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) - ]] - local text = luatool.handle({ prompt = "x", source = source }, profiles) - has(text, "Error:") - has(text, "pcall") - end) - end }, - - { "inline agent profiles are scoped to one workflow invocation", 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() + ]] }, 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") + + local missing = luatool.handle({ source = [[ + return subagents.workflow(function(ctx) + ctx:agent{agent="alpha", prompt="one"} + 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) - - 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() + ]] }, 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'") -- cgit v1.3