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. --- e2e/run.lua | 83 +++++++++++++++++++++++++++++++++++++++++-------------------- 1 file changed, 56 insertions(+), 27 deletions(-) (limited to 'e2e') diff --git a/e2e/run.lua b/e2e/run.lua index 56deda6..be58a00 100644 --- a/e2e/run.lua +++ b/e2e/run.lua @@ -466,8 +466,8 @@ end -- 1. One tool batch, seven calls: two children that must overlap, three that -- report what they were handed, one that fails mid-stream, and one unknown --- profile that never becomes a child. Six jobs against a bound of four also --- means two of them start only when a slot frees. +-- profile that never becomes a child. Six jobs against a bound of five also +-- means one of them starts only when a slot frees. scenario("parallel-batch", function() local install = make_install("parallel-batch") local script = table.concat({ @@ -487,7 +487,7 @@ scenario("parallel-batch", function() "Error: unknown agent 'ghost'; known: alpha, beta") check_contains("child A reported its rendezvous", out, "pair:A") check_contains("child B reported its rendezvous", out, "pair:B") - check_contains("the primary's system context reached a child", out, "sys-found PRIMARY-CONTEXT-MARK") + check_contains("the primary's system context did not reach a child", out, "sys-missing PRIMARY-CONTEXT-MARK") -- One child's stream failed; its five siblings settled on their own terms. check_equal("five children completed", count_occurrences(out, "status: completed"), 5) @@ -601,15 +601,41 @@ scenario("parallel-batch", function() end check_contains(who .. ": the child-role instruction was seeded", text, "You are a subagent working inside another agent's session") - check_contains(who .. ": the primary's system context was seeded", text, - "PRIMARY-CONTEXT-MARK") + local primary_context_in_system = false + for _, line in ipairs(lines_of(text)) do + if line:find('"role":"system"', 1, true) + and line:find("PRIMARY-CONTEXT-MARK", 1, true) then + primary_context_in_system = true + break + end + end + check(who .. ": the primary's system context was not seeded", + not primary_context_in_system, excerpt(text, 400)) end check_equal("every child carries one manifest", seen_manifest, 5) check_equal("every child recorded one turn's model/reasoning", seen_turn, 5) check_equal("the two beta children ran on the profile's model", seen_beta, 2) end) --- 2. A child outlives its process: a second `panto --resume` continues it, and a +-- 2. A one-off child can define its own system prompt without a profile or a +-- workflow wrapper. +scenario("inline-run", function() + local install = make_install("inline-run") + local out = turn(install, + [[tool subagents.run {"system_prompt":"INLINE-RUN-MARK","prompt":"sys INLINE-RUN-MARK"}]]) + check_contains("the inline child completed", out, "status: completed") + check_contains("the inline prompt reached the child", out, "sys-found INLINE-RUN-MARK") + check_contains("the inline child has a stable result label", out, "agent: subagent") + + local dir = assert(session_dir(install)) + local primary_id = jsonl_ids(dir)[1] + local children = jsonl_ids(assert(child_dir(install, primary_id))) + check_equal("the inline child persisted normally", #children, 1) + local text = read_file(assert(child_dir(install, primary_id)) .. "/" .. children[1] .. ".jsonl") or "" + check_contains("the inline manifest is replay-visible", text, meta('"inline":true')) +end) + +-- 3. A child outlives its process: a second `panto --resume` continues it, and a -- third, unrelated primary cannot. scenario("resume", function() local install = make_install("resume") @@ -662,7 +688,7 @@ scenario("resume", function() string.format("Error: unknown subagent id '%s' for this session", child_id)) end) --- 3. A discovered TOML workflow: a diamond, so both branches run concurrently +-- 4. A discovered TOML workflow: a diamond, so both branches run concurrently -- off one root and the sink sees both labelled outputs. scenario("workflow-diamond", function() local install = make_install("workflow-diamond") @@ -680,13 +706,14 @@ scenario("workflow-diamond", function() check_equal("four steps became four children", #jsonl_ids(assert(child_dir(install, primary_id))), 4) end) --- 4. The restricted `subagents.lua` sandbox: a one-shot structured worker whose +-- 5. The restricted `subagents.lua` sandbox: a one-shot structured worker whose -- decoded output drives the fan-out. scenario("sandbox-fanout", function() local install = make_install("sandbox-fanout") local source = table.concat({ - "return subagents.workflow(function(ctx, input)", + "return subagents.workflow(function(ctx)", " local split = ctx:agent({", + " name = 'split',", " agent = 'alpha',", [[ prompt = 'emit emit_result {\"items\":[\"X\",\"Y\"]}',]], " output = { description = 'the work items', schema = { type = 'object',", @@ -695,37 +722,44 @@ scenario("sandbox-fanout", function() " }):await()", " local handles = {}", " for _, item in ipairs(split.output.items) do", - " handles[#handles + 1] = ctx:agent({ agent = 'beta', prompt = 'say ITEM-' .. item })", + " handles[#handles + 1] = ctx:agent({ name = 'item-' .. item, agent = 'beta', prompt = 'say ITEM-' .. item })", " end", - " return ctx:await(handles, 'all')", + " local results = ctx:await(handles, 'all')", + " return results[1].output .. '\\\\n' .. results[2].output", "end)", }, "\\n") local out = turn(install, string.format( - [[tool subagents.lua {"prompt":"FANOUT-INPUT","source":"%s"}]], source)) + [[tool subagents.lua {"source":"%s"}]], source)) - check_contains("the first fan-out branch ran", out, "ITEM-X") - check_contains("the second fan-out branch ran", out, "ITEM-Y") - check_equal("the fan-out produced two results", count_occurrences(out, "status: completed"), 2) + check_contains("the workflow id returned immediately", out, "workflow-") -- The one-shot worker is ephemeral: only the two conversational children -- have a durable file. local dir = assert(session_dir(install)) local primary_id = jsonl_ids(dir)[1] - check_equal("the structured worker left no session behind", - #jsonl_ids(assert(child_dir(install, primary_id))), 2) + local children = assert(child_dir(install, primary_id)) + local ids = jsonl_ids(children) + check_equal("the structured worker left no session behind", #ids, 2) + local persisted = "" + for _, id in ipairs(ids) do persisted = persisted .. (read_file(children .. "/" .. id .. ".jsonl") or "") end + check_contains("the first fan-out branch ran", persisted, "ITEM-X") + check_contains("the second fan-out branch ran", persisted, "ITEM-Y") end) --- 5. An inline profile in the dynamic workflow gets a durable marker and +-- 6. An inline profile in the dynamic workflow gets a durable marker and -- body, so restart replay can show the requested system prompt without -- exposing discovered profile prompts. scenario("inline-replay-manifest", function() local install = make_install("inline-replay-manifest") - local source = "return subagents.workflow(function(ctx, input) return ctx:agent({ agent = 'inline', prompt = 'say INLINE-CHILD' }):await() end)" + local source = [[return subagents.workflow(function(ctx) + local result = ctx:agent({ name = 'inline-work', agent = 'inline', prompt = 'say INLINE-CHILD' }):await() + return result.output + end)]] local script = string.format( - 'tool subagents.lua {"prompt":"INLINE-INPUT","source":%s,"agents":[{"name":"inline","system_prompt":"INLINE-SYSTEM"}]}', + 'tool subagents.lua {"source":%s,"agents":[{"name":"inline","system_prompt":"INLINE-SYSTEM"}]}', json_string(source)) local out = turn(install, script) - check_contains("the inline workflow child completed", out, "INLINE-CHILD") + check_contains("the inline workflow id returned", out, "workflow-") local dir = assert(session_dir(install)) local primary_id = jsonl_ids(dir)[1] @@ -737,15 +771,13 @@ scenario("inline-replay-manifest", function() check_contains("the inline system prompt remains durable", text, "INLINE-SYSTEM") end) --- 6. The catalog tool, in all four shapes, against a provider whose reasoning +-- 7. The catalog tool, in its three shapes, against a provider whose reasoning -- levels come from the protocol rather than models.toml. scenario("catalog", function() local install = make_install("catalog") local out = turn(install, table.concat({ [[tool subagents.models {}]], [[tool subagents.models {"model":"fixture-provider:beta"}]], - [[tool subagents.models {"agent":"alpha"}]], - [[tool subagents.models {"agent":"beta"}]], [[tool subagents.models {"query":"beta"}]], [[tool subagents.models {"model":"fixture-provider:nope"}]], }, " && ")) @@ -755,9 +787,6 @@ scenario("catalog", function() check_contains("the overview counts the fixture provider's models", out, "fixture-provider (") check_contains("the exact lookup resolves the wire name", out, "wire model: beta-wire") check_contains("the exact lookup reports the protocol's effort levels", out, "reasoning levels: tiny, deep") - check_contains("a profile without a model says it inherits", out, "model: inherits the primary model") - check_contains("a profile with a model resolves it", out, "model: fixture-provider:beta") - check_contains("a profile's reasoning is reported", out, "profile reasoning: deep") check_contains("the search found the model", out, "1 match(es):") check_contains("an unknown model is reported, not resolved", out, "No configured model matches 'fixture-provider:nope'") -- cgit v1.3