diff options
| -rw-r--r-- | README.md | 77 | ||||
| -rw-r--r-- | e2e/run.lua | 83 | ||||
| -rw-r--r-- | init.lua | 165 | ||||
| -rw-r--r-- | panto-subagents-0.1.0-1.rockspec | 5 | ||||
| -rw-r--r-- | spec/fake_ext.lua | 33 | ||||
| -rw-r--r-- | spec/test_init.lua | 121 | ||||
| -rw-r--r-- | spec/test_jobs.lua | 36 | ||||
| -rw-r--r-- | spec/test_luatool.lua | 330 | ||||
| -rw-r--r-- | spec/test_models.lua | 49 | ||||
| -rw-r--r-- | spec/test_profiles.lua | 16 | ||||
| -rw-r--r-- | spec/test_progress_replay.lua | 23 | ||||
| -rw-r--r-- | spec/test_run.lua | 42 | ||||
| -rw-r--r-- | spec/test_toml_workflows.lua | 161 | ||||
| -rw-r--r-- | spec/test_workflow.lua | 41 | ||||
| -rw-r--r-- | subagents/jobs.lua | 35 | ||||
| -rw-r--r-- | subagents/luatool.lua | 80 | ||||
| -rw-r--r-- | subagents/models.lua | 56 | ||||
| -rw-r--r-- | subagents/paths.lua | 36 | ||||
| -rw-r--r-- | subagents/profiles.lua | 12 | ||||
| -rw-r--r-- | subagents/progress.lua | 14 | ||||
| -rw-r--r-- | subagents/run.lua | 9 | ||||
| -rw-r--r-- | subagents/spawn.lua | 82 | ||||
| -rw-r--r-- | subagents/toml_workflows.lua | 138 | ||||
| -rw-r--r-- | subagents/workflow.lua | 260 |
24 files changed, 1212 insertions, 692 deletions
@@ -11,7 +11,14 @@ run several at once, and continue their conversations later. rocks = ["panto-subagents"] ``` -For a local checkout, use `paths = ["/path/to/panto-subagents"]` instead. +For a local checkout, use `paths = ["/path/to/panto-subagents"]` instead. A +`paths` entry loads the code but installs nothing, so put the dependencies below +in panto's own rocks tree first: + +```sh +panto lua -e 'require("luarocks.cmd").run("install", "toml2lua")' +panto lua -e 'require("luarocks.cmd").run("install", "lyaml")' +``` ### Dependencies @@ -21,7 +28,8 @@ Panto installs the rock's dependencies with it: LuaRocks does not vendor — install it first (`brew install libyaml`, or `apt install libyaml-dev`), otherwise the rock fails to build and panto quietly starts without the `subagents.*` tools. -- **toml2lua** reads TOML workflows. Pure Lua, nothing to install. +- **toml2lua** reads the layered `config.toml` at activation and TOML workflows + afterwards. Pure Lua, nothing to install; without it activation fails. - **luv** backs profile and workflow discovery. Panto already ships it. Structured workflow output is validated against its JSON Schema by a built-in @@ -29,6 +37,18 @@ validator covering the schema subset those results use. Installing the `jsonschema` rock switches validation over to it; that rock needs a system PCRE, so it is not a declared dependency. +## Configuration + +At most five children run concurrently by default; additional children queue. +Override the limit in any layered `config.toml` (later layers win): + +```toml +[subagents] +max_concurrent = 10 +``` + +`max_concurrent` must be a positive integer. + ## Agent profiles Agents are Markdown files with YAML frontmatter: @@ -44,12 +64,11 @@ reasoning: high You are a focused code reviewer. Report only concrete findings. ``` -Profiles are loaded recursively from: - -- `${XDG_CONFIG_HOME:-$HOME/.config}/panto/agents/` -- `.panto/agents/` in the current project +Profiles are loaded recursively from `agents/` beneath every Panto config +layer, lowest precedence first: the built-in base layer, +`${XDG_CONFIG_HOME:-$HOME/.config}/panto/`, `.panto/`, and `.panto/local/`. -Project profiles override user profiles with the same name. `name` defaults to +A more local profile overrides an earlier one with the same name. `name` defaults to the filename. `model` and `reasoning` are optional; each value follows the same precedence: tool call, then profile, then the primary agent. Models use full `provider:model` names. @@ -65,6 +84,18 @@ subagents.run { } ``` +A one-off child can instead take an inline system prompt, without a saved +profile: + +```lua +subagents.run { + system_prompt = "You are a focused code reviewer.", + prompt = "Review the authentication changes.", +} +``` + +`agent`, `system_prompt`, and `id` are mutually exclusive. + Every started child returns an ID. Passing it back resumes the same conversation, including after restarting Panto and resuming the primary: @@ -75,9 +106,9 @@ subagents.run { } ``` -Children receive the primary's system and project context plus their own -profile prompt, but not the primary's conversation. They share its workspace -and tools except for `subagents.*`. +Children start a fresh conversation with the fixed child-role instruction and +their profile prompt; the primary's system prompt and conversation are not +copied. They share its workspace and tools except for `subagents.*`. Multiple calls in one tool batch run concurrently. Their tagged assistant and tool activity is visible in the TUI while they work. Child conversations are @@ -92,18 +123,24 @@ entire model catalog in every tool prompt. ## Workflows TOML workflows define fixed dependency graphs. They are loaded from -`${XDG_CONFIG_HOME:-$HOME/.config}/panto/workflows/` and -`.panto/workflows/`, then exposed as `/workflow:<name>` commands. Ready steps +`workflows/` beneath the same config layers as profiles, then exposed as +`/workflow:<name>` commands. Ready steps run concurrently; each dependent step receives its predecessors' labeled -outputs. +outputs. The workflow reports its terminal steps, or exactly the steps a +top-level `output = ["id", ...]` array names, in that order. + +The Lua workflow API handles dynamic branching and fan-out. `subagents.lua` +starts model-authored workflows in a restricted environment and returns a +session-scoped workflow ID immediately. Work continues on Pantograph's event +loop; completion wakes the primary, and later calls can inspect immutable +records through `subagents.workflows[id]`, including named child status and +outputs. Every `ctx:agent` needs a workflow-unique `name`, and the callback must +return the workflow's string result. -The Lua workflow API handles dynamic branching and fan-out. It can await one -job or a group, and supports one-turn workers whose validated tool input is -their structured result. `subagents.lua` may also define workflow-local agent -profiles inline, so a primary can create specialized workers without writing -profile files or restarting Panto; those profiles disappear when the tool call -ends. Panto extensions can use this API directly, while `subagents.lua` runs -model-authored one-off workflows in a restricted Lua environment. +Inline `agents` remain scoped to workflows started by that tool call. The API +can await one job or a group and supports one-turn workers whose validated tool +input is their structured result. Persistent TOML workflows remain the simpler +fixed-DAG surface. ## Development 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'") @@ -1,9 +1,10 @@ -- panto-subagents: the `subagents` extension entry point. -- -- Activation happens once per panto session (a new session, `/new`, or --- `/resume` rebuilds the whole Lua state and runs this again). It discovers --- the agent profiles once, registers the four model-facing tools, and --- registers a `/workflow:<name>` command for every valid TOML workflow. +-- `/resume` rebuilds the whole Lua state and runs this again). It reads the +-- layered `[subagents]` settings, discovers the agent profiles once, registers +-- the four model-facing tools, and registers a `/workflow:<name>` command for +-- every valid TOML workflow. -- -- Discovery order is deterministic and side-effect free apart from -- registration, because pantograph evaluates every candidate extension file @@ -17,10 +18,11 @@ -- of the run description — the one place the user and the model both see the -- profile list. -- --- Activation also subscribes to the turn lifecycle: an interrupted turn asks --- every live child to stop, and the end of a turn joins them and clears the --- coroutine bindings. Settled progress cards remain attached to their --- transcript entries until the host destroys those components. +-- Activation also subscribes to the session lifecycle: interruption cancels +-- live work, ordinary turn boundaries reap only settled jobs so background +-- workflows survive, and session teardown cancels everything. Settled progress +-- cards remain attached to their transcript entries until the host destroys +-- those components. -- -- If the host predates the model-resolution seam, activation fails loudly -- instead of registering tools that cannot work. @@ -28,12 +30,15 @@ local jobs = require("subagents.jobs") local luatool = require("subagents.luatool") local models = require("subagents.models") +local paths = require("subagents.paths") local progress = require("subagents.progress") local run = require("subagents.run") local spawn = require("subagents.spawn") local toml_workflows = require("subagents.toml_workflows") +local workflow_api = require("subagents.workflow") local MAX_SHOWN_WARNINGS = 5 +local DEFAULT_MAX_CONCURRENT = 5 local DIM, RESET = "\27[2m", "\27[0m" local function host() @@ -102,7 +107,7 @@ end local function run_description(profiles) local lines = { - "Delegate a task to a subagent and wait for its report. Start a new child from an agent profile, or continue a child you started earlier in this session.", + "Delegate a task to a subagent and wait for its report. Start a new child from an agent profile or an inline system prompt, or continue a child you started earlier in this session.", "", "Agent profiles:", } @@ -120,7 +125,7 @@ local function run_description(profiles) lines[#lines + 1] = "" lines[#lines + 1] = "Rules:" - lines[#lines + 1] = "- Pass exactly one of `agent` (start a new child from that profile) or `id` (continue a child from an earlier result). `prompt` is always required and must be non-empty." + lines[#lines + 1] = "- Pass exactly one of `agent` (start from a profile), `system_prompt` (start without a profile), or `id` (continue a child). `prompt` is always required and must be non-empty." lines[#lines + 1] = "- Omit `model` and `reasoning` normally; a child inherits yours. Call subagents.models before choosing an unfamiliar model or reasoning level." lines[#lines + 1] = "- A child shares your workspace and tools but not your conversation, and cannot ask the user questions. Put every piece of task context it needs into `prompt`." lines[#lines + 1] = "- To delegate in parallel, emit several subagents.run calls in one tool batch; they run concurrently and one failure does not discard the others." @@ -141,12 +146,99 @@ local function run_description(profiles) return table.concat(lines, "\n") end +local SUBAGENTS_GUIDANCE = [=[ +## Subagents + +Use `subagents.run` for one delegation, or emit several `subagents.run` calls in one tool batch for simple independent parallel work. Use `subagents.lua` for dynamic fan-out, branching, staged waves, or work that should continue while you interact with the user. + +`subagents.lua` takes Lua `source` and optional inline `agents`; it has no shared prompt. Put the complete, specific context each worker needs in that worker's `prompt`. Start a background workflow and save its returned id: + +```lua +return subagents.workflow(function(ctx) + local a = ctx:agent{name="api", system_prompt="Research APIs.", prompt="Inspect the API surface."} + local b = ctx:agent{name="tests", system_prompt="Research tests.", prompt="Inspect test coverage."} + local research = ctx:await({a, b}, "all") + + local implementation = ctx:agent{ + name="implement", + system_prompt="Implement focused changes.", + prompt="Implement using this research:\n" .. research[1].output .. "\n" .. research[2].output, + }:await() + + local reviewers = { + ctx:agent{name="correctness", system_prompt="Review correctness.", prompt=implementation.output}, + ctx:agent{name="simplicity", system_prompt="Review for simplicity.", prompt=implementation.output}, + } + local reviews = ctx:await(reviewers, "all") + return reviews[1].output .. "\n\n" .. reviews[2].output +end) +``` + +Every `ctx:agent` needs a non-empty `name` unique within that workflow. A workflow callback must return a string. Inspect a running or finished workflow with a later `subagents.lua` call, for example: + +```lua +local w = subagents.workflows["workflow-1"] +return w.status .. (w.result and ("\n" .. w.result) or "") +``` + +Workflow fields are `id`, `status`, `result`, `error`, and `agents`. Agents are available by name or iteration and expose `name`, `status`, `output`, `error`, and `id`. Records are read-only. Workflow completion wakes you with its id; use another `subagents.lua` call to retrieve whichever outputs you need. +]=] + +local function install_guidance(ext) + subscribe(ext, "session_start", function() + local primary = ext.agent + if primary and type(primary.add_system_message) == "function" then + primary:add_system_message(SUBAGENTS_GUIDANCE) + end + end) +end + +local function configured_max_concurrent(ext) + local value = DEFAULT_MAX_CONCURRENT + local layers = ext.dirs and ext.dirs.layers + if type(layers) ~= "table" then + return value + end + + local ok_toml, toml = pcall(require, "toml") + if not ok_toml or type(toml) ~= "table" or type(toml.parse) ~= "function" then + error("panto-subagents: the 'toml2lua' rock is required to read configuration") + end + + for _, layer in ipairs(layers) do + if type(layer) == "table" and type(layer.dir) == "string" then + local path = layer.dir .. "/config.toml" + local text = paths.read_file(path) + if text then + local parsed_ok, parsed = pcall(toml.parse, text, { strict = true }) + if not parsed_ok or type(parsed) ~= "table" then + error("panto-subagents: could not parse " .. path) + end + local section = parsed.subagents + if section ~= nil and type(section) ~= "table" then + error("panto-subagents: [subagents] must be a table in " .. path) + end + local configured = section and section.max_concurrent + if configured ~= nil then + if type(configured) ~= "number" or configured < 1 or configured ~= math.floor(configured) then + error("panto-subagents: subagents.max_concurrent must be a positive integer in " .. path) + end + value = configured + end + end + end + end + return value +end + local function activate() local ext = host() if type(ext.resolve_model) ~= "function" or type(require("panto").agent) ~= "function" then error("panto-subagents: this pantograph is too old for subagents (panto.ext.resolve_model is missing)") end + jobs.MAX_CONCURRENT = configured_max_concurrent(ext) + -- Discover through spawn so the workflow lanes, which resolve profiles -- lazily, share the exact set this tool description advertises. local profiles = spawn.profiles() @@ -161,9 +253,16 @@ local function activate() progress.begin_live_turn() end) subscribe(ext, "turn_interrupt", function() + workflow_api.cancel_all(false) jobs.cancel_all() end) subscribe(ext, "turn_end", function() + jobs.reap() + progress.reset() + end) + subscribe(ext, "session_end", function() + workflow_api.cancel_all(true) + jobs.cancel_all() jobs.close_all() progress.reset() end) @@ -184,10 +283,11 @@ local function activate() schema = { type = "object", properties = { - agent = { type = "string", description = "Profile name for a new child. Mutually exclusive with `id`." }, - id = { type = "string", description = "Id of a child started earlier in this session, to continue it. Mutually exclusive with `agent`." }, + agent = { type = "string", description = "Profile name for a new child. Mutually exclusive with `system_prompt` and `id`." }, + system_prompt = { type = "string", description = "System prompt for a new child without a profile. Mutually exclusive with `agent` and `id`." }, + id = { type = "string", description = "Id of a child started earlier in this session, to continue it. Mutually exclusive with `agent` and `system_prompt`." }, prompt = { type = "string", description = "The complete task for the child. It sees none of this conversation." }, - model = { type = "string", description = "Optional `provider:model` override. Omit to inherit." }, + model = { type = "string", description = "Optional `provider:model` override. Omit to inherit; query available options with `subagents.models`." }, reasoning = { type = "string", description = "Optional reasoning level override. Omit to inherit." }, }, required = { "prompt" }, @@ -202,7 +302,7 @@ local function activate() ext.register_tool { name = "subagents.models", - description = "Query the configured model catalog: no arguments for the inherited model plus provider counts, `model` for an exact lookup including valid reasoning levels, `provider`/`query` for a bounded search, or `agent` for what a profile will actually run on.", + description = "Query the configured model catalog: no arguments for the inherited model plus provider counts, `model` for an exact lookup including valid reasoning levels, or `provider`/`query` for a bounded search.", schema = { type = "object", properties = { @@ -210,22 +310,20 @@ local function activate() query = { type = "string", description = "Substring to search model names for." }, limit = { type = "integer", description = "Maximum matches to return (1-50, default 10).", minimum = 1, maximum = 50 }, model = { type = "string", description = "Exact `provider:model` to look up." }, - agent = { type = "string", description = "Agent profile whose effective model to report." }, }, }, handler = function(input) - return models.handle(input, profiles) + return models.handle(input) end, } ext.register_tool { name = "subagents.lua", - description = "Run a one-off Lua workflow that fans several subagents out and combines their results. `source` must return subagents.workflow(function(ctx, input) ... end) and runs in a restricted environment with no filesystem, process, or module access. Optional `agents` define profiles available only to this workflow.", + description = "Run sandboxed Lua that starts background subagent workflows or inspects existing ones. `source` can call subagents.workflow(function(ctx) ... end), which returns a workflow id immediately, and can read immutable records from subagents.workflows. Optional `agents` define profiles available only to workflows started by this call.", schema = { type = "object", properties = { - prompt = { type = "string", description = "The workflow input, passed to the callback as its second argument." }, - source = { type = "string", description = "Lua source returning subagents.workflow(function(ctx, input) ... end)." }, + source = { type = "string", description = "Lua source that starts a workflow or inspects subagents.workflows." }, agents = { type = "array", description = "Agent profiles available only to this workflow. Inline profiles shadow discovered profiles with the same name.", @@ -240,11 +338,11 @@ local function activate() }, }, }, - required = { "prompt", "source" }, + required = { "source" }, }, handler = function(input, context) progress.bind(context) - local result = luatool.handle(input, profiles) + local result = luatool.handle(input, profiles, context) progress.settle(context) return result end, @@ -252,34 +350,14 @@ local function activate() ext.register_tool { name = "subagents.workflow", - description = "Run a fixed dependency graph of subagents: `name` runs a discovered TOML workflow, or `steps` defines one inline. Every step receives `prompt` as the workflow input, and a step with `needs` also receives those steps' outputs.", + description = "Run a discovered TOML workflow by `name`: a fixed dependency graph of subagents. Every step receives `prompt` as the workflow input, and a step with `needs` also receives those steps' outputs. For dynamic graphs, use `subagents.lua`.", schema = { type = "object", properties = { - name = { type = "string", description = "Name of a discovered workflow. Mutually exclusive with `steps`." }, + name = { type = "string", description = "Name of a discovered workflow." }, prompt = { type = "string", description = "The workflow input, given to every step." }, - steps = { - type = "array", - description = "Inline workflow definition. Mutually exclusive with `name`.", - items = { - type = "object", - properties = { - id = { type = "string", description = "Unique step id." }, - agent = { type = "string", description = "Agent profile to run this step." }, - prompt = { type = "string", description = "Step instruction." }, - model = { type = "string", description = "Optional `provider:model` override." }, - reasoning = { type = "string", description = "Optional reasoning level override." }, - needs = { - type = "array", - description = "Ids of steps whose output this step receives.", - items = { type = "string" }, - }, - }, - required = { "id", "agent", "prompt" }, - }, - }, }, - required = { "prompt" }, + required = { "name", "prompt" }, }, handler = function(input, context) progress.bind(context) @@ -290,6 +368,7 @@ local function activate() } local workflows = toml_workflows.discover_and_register(profiles) + install_guidance(ext) install_header(ext, profiles, workflows) end diff --git a/panto-subagents-0.1.0-1.rockspec b/panto-subagents-0.1.0-1.rockspec index 693629e..5ae55ff 100644 --- a/panto-subagents-0.1.0-1.rockspec +++ b/panto-subagents-0.1.0-1.rockspec @@ -11,8 +11,9 @@ -- Dependencies. `lyaml` parses profile frontmatter and is a C binding over the -- system libyaml, which luarocks does not vendor: a machine without it needs -- `brew install libyaml` (or `apt install libyaml-dev`) first, and may need --- `YAML_DIR=...` passed to luarocks. `toml2lua` (module name `toml`) reads TOML --- workflows and is pure Lua. `luv` backs the recursive discovery walk and the +-- `YAML_DIR=...` passed to luarocks. `toml2lua` (module name `toml`) reads the +-- layered `config.toml` at activation and the TOML workflows afterwards, so it +-- is required, not optional; it is pure Lua and needs nothing from the system. `luv` backs the recursive discovery walk and the -- wake pipes child jobs are polled on, and ships with panto as a pinned -- battery, so it is normally already present in the tree this rock installs -- into. 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'") diff --git a/subagents/jobs.lua b/subagents/jobs.lua index a774b27..9fe5c93 100644 --- a/subagents/jobs.lua +++ b/subagents/jobs.lua @@ -11,10 +11,9 @@ -- an edge trigger, never a count, so every wake drains the pipe, then drains -- `job:next_event()` to exhaustion, then checks `job:result()`. -- --- Waiting parks the CALLING coroutine — the tool handler's — and a poll --- callback resumes exactly that coroutine. subagents/workflow.lua's rule that --- a workflow callback must never run on a nested coroutine follows from this: --- the parked thread is the one the resume goes to. +-- Waiting parks the CALLING coroutine and a poll callback resumes that exact +-- coroutine. Foreground tools use their handler coroutine; background +-- workflows provide a dedicated coroutine anchored in their registry record. -- -- The wake pipe is therefore mandatory wherever luv is: a started job whose -- pipe or poll could not be armed is a start failure, not a degraded job. @@ -43,9 +42,9 @@ local READ_CHUNK = 4096 local M = {} --- The session-wide bound. A field, not a constant, so a caller (or a spec) can --- lower it without reaching into the queue. -M.MAX_CONCURRENT = 4 +-- The activation-time default is five; init.lua may replace it from the +-- layered `[subagents] max_concurrent` setting before any child can start. +M.MAX_CONCURRENT = 5 local handle_mt = {} handle_mt.__index = handle_mt @@ -58,6 +57,7 @@ local queued = {} local waiters = {} local running = 0 local pumping = false +local cancelling = false -- --------------------------------------------------------------------------- -- Wake pipes @@ -191,7 +191,7 @@ end -- Start queued jobs while the gate has room. Reentrant: a job that settles the -- instant it starts calls back in here, and the outer loop keeps going. function pump_queue() - if pumping then + if pumping or cancelling then return end pumping = true @@ -442,9 +442,28 @@ end -- The turn was interrupted: ask every child to stop. Cancellation is a request, -- not a settle — each job still reports its own cancelled result. function M.cancel_all() + cancelling = true for index = #live, 1, -1 do live[index]:cancel() end + cancelling = false + pump_queue() + wake() +end + +-- Close settled jobs without disturbing queued or running background work. +-- Called at ordinary turn boundaries; session teardown still uses close_all. +function M.reap() + local kept = {} + for _, handle in ipairs(live) do + if handle.settled ~= nil then + close_pipe(handle) + close_job(handle) + else + kept[#kept + 1] = handle + end + end + live = kept end -- The turn is over: cancel every child and drop the state. Never blocks — a diff --git a/subagents/luatool.lua b/subagents/luatool.lua index 53966aa..dee7398 100644 --- a/subagents/luatool.lua +++ b/subagents/luatool.lua @@ -1,11 +1,12 @@ -- subagents/luatool.lua -- -- The `subagents.lua` model-facing tool: run a transient, model-authored Lua --- workflow without writing a definition to disk. The source must evaluate to --- `subagents.workflow(function(ctx, input) ... end)`; the tool then executes it --- with the tool's `prompt` as the workflow input and formats the terminal --- results for the calling model. Optional inline agent profiles are overlaid --- for this execution only; they are never persisted or added to discovery. +-- workflow without writing a definition to disk. Source can start one with +-- `subagents.workflow(function(ctx) ... end)`, which immediately returns a +-- workflow id, or inspect a prior run through `subagents.workflows[id]` and +-- return any model-visible value. Optional inline agent profiles are overlaid +-- for workflows started by this call only; they are never persisted or added +-- to discovery. -- -- The source is loaded in text mode only (`load(source, chunkname, "t", env)`) -- against a restricted `_ENV`. That environment holds a safe slice of the @@ -37,13 +38,8 @@ -- -- Runaway generated Lua is bounded two ways: `max_jobs = 32` caps how many -- children one transient workflow may start, and a debug count hook is armed --- for the duration of the guest callback and disarmed as soon as it returns. --- The hook lives here rather than in workflow.lua because the guest has no --- `debug` library but the host does; workflow.execute only exposes the --- on_resume/on_yield seam the hook needs. The guest runs on the tool handler's --- own coroutine (an await parks and resumes exactly that coroutine when a child --- settles), so the budget covers the whole run rather than one slice; awaiting --- a child executes no instructions, so only real spinning trips it. +-- on each background workflow coroutine for its full execution. Top-level +-- source evaluation gets the same budget before it can schedule anything. local workflow = require("subagents.workflow") local run = require("subagents.run") @@ -69,7 +65,8 @@ end -- Build a fresh restricted environment per call: the guest may mutate anything -- it can reach, so nothing here is shared between invocations. -local function build_env() +local function build_env(schedule) + schedule = schedule or workflow.workflow local env = { assert = assert, error = error, @@ -85,7 +82,10 @@ local function build_env() math = shallow_copy(math), utf8 = shallow_copy(utf8), print = function() end, - subagents = { workflow = workflow.workflow }, + subagents = { + workflow = schedule, + workflows = workflow.workflows, + }, } env._G = env return env @@ -205,13 +205,10 @@ end -- Tool handler for `subagents.lua`. `profiles` is the discovered profile set -- from activation; when omitted the workflow API discovers it lazily. -function M.handle(input, profiles) +function M.handle(input, profiles, context) if type(input) ~= "table" then return "Error: expected an input object" end - if type(input.prompt) ~= "string" or input.prompt == "" then - return "Error: prompt is required and must be a non-empty string" - end if type(input.source) ~= "string" or input.source == "" then return "Error: source is required and must be a non-empty string" end @@ -221,40 +218,35 @@ function M.handle(input, profiles) return "Error: " .. profiles_err end - local chunk, load_err = load(input.source, CHUNK_NAME, "t", build_env()) + local function on_resume(co) + debug.sethook(co, budget_hook, "", INSTRUCTION_BUDGET) + end + local function on_yield(co) + debug.sethook(co) + end + local function schedule(fn) + return workflow.start(workflow.workflow(fn), { + max_jobs = MAX_JOBS, + profiles = profiles_for_run, + tool_call_id = type(context) == "table" and context.tool_call_id or nil, + on_resume = on_resume, + on_yield = on_yield, + }) + end + + local chunk, load_err = load(input.source, CHUNK_NAME, "t", build_env(schedule)) if not chunk then return "Error: source did not compile: " .. tostring(load_err) end + local co = coroutine.running() + if co then debug.sethook(co, budget_hook, "", INSTRUCTION_BUDGET) end local built_ok, built = pcall(chunk) + if co then debug.sethook(co) end if not built_ok then return "Error: source failed to run: " .. tostring(built) end - if not workflow.is_workflow(built) then - return "Error: source must return subagents.workflow(function(ctx, input) ... end)" - end - - local armed = nil - local ran_ok, result = pcall(workflow.execute, built, input.prompt, { - max_jobs = MAX_JOBS, - profiles = profiles_for_run, - on_resume = function(co) - armed = co - debug.sethook(co, budget_hook, "", INSTRUCTION_BUDGET) - end, - on_yield = function(co) - debug.sethook(co) - armed = nil - end, - }) - if armed then - debug.sethook(armed) - end - - if not ran_ok then - return "Error: " .. tostring(result) - end - return format_return(result) + return format_return(built) end return M diff --git a/subagents/models.lua b/subagents/models.lua index 511aa51..7272298 100644 --- a/subagents/models.lua +++ b/subagents/models.lua @@ -1,17 +1,12 @@ -- The `subagents.models` tool: a bounded window onto the model catalog. -- -- The catalog is far too large to inline into `subagents.run`'s description or --- schema, so it is queried on demand instead. Four forms, chosen in this order: +-- schema, so it is queried on demand instead. Three forms: -- --- { agent = "reviewer" } what that profile will actually run on -- { model = "anthropic:sonnet"} exact lookup: wire name, reasoning levels -- { provider =, query =, limit=} bounded search -- { } inherited model/reasoning + provider counts -- --- The agent form is the join the primary actually wants before overriding a --- profile: it reports the profile's own model, or says the profile inherits --- the primary model and shows what that is. --- -- Results are short readable lines, not JSON. A truncated search says so -- explicitly so the primary refines the query instead of assuming it saw -- everything; `limit` is clamped to 1..50 (default 10) so no query can dump @@ -22,8 +17,6 @@ -- tool cannot confirm is still validated at spawn time — the catalog is -- advice, the runtime is authoritative. -local spawn = require("subagents.spawn") - local DEFAULT_LIMIT = 10 local MAX_LIMIT = 50 @@ -143,52 +136,13 @@ local function clamp_limit(value) return limit end --- The agent form: report the profile's effective model, or say it inherits. -local function describe_agent(name, profiles) - profiles = spawn.profiles(profiles) - local profile = (profiles.by_name or {})[name] - if not profile then - return string.format("Error: unknown agent '%s'; known: %s", name, spawn.agent_names(profiles)) - end - - local out = { "agent: " .. profile.name } - if profile.description ~= "" then - out[#out + 1] = "description: " .. profile.description - end - - if profile.model then - local result, err = ask({ model = profile.model }) - if not result then - return "Error: " .. err - end - out[#out + 1] = format_exact(result, profile.model) - else - out[#out + 1] = "model: inherits the primary model" - local result, err = ask({}) - if not result then - return "Error: " .. err - end - out[#out + 1] = format_overview(result) - end - - if profile.reasoning then - out[#out + 1] = "profile reasoning: " .. profile.reasoning - end - return table.concat(out, "\n") -end - -function M.handle(input, profiles) +function M.handle(input) input = input or {} if type(input) ~= "table" then return "Error: expected a table of arguments." end - local agent, err = optional_string(input.agent, "agent") - if err then - return err - end - local model - model, err = optional_string(input.model, "model") + local model, err = optional_string(input.model, "model") if err then return err end @@ -203,10 +157,6 @@ function M.handle(input, profiles) return err end - if agent then - return describe_agent(agent, profiles) - end - if model then local result, ask_err = ask({ model = model }) if not result then diff --git a/subagents/paths.lua b/subagents/paths.lua index 678e241..8b6d9fa 100644 --- a/subagents/paths.lua +++ b/subagents/paths.lua @@ -2,12 +2,13 @@ -- -- Two jobs live here: -- --- 1. Config-layer discovery. `config_roots("agents")` returns the two +-- 1. Config-layer discovery. `config_roots("agents")` returns the -- directories profiles (or workflows) are read from, lowest precedence --- first: `${XDG_CONFIG_HOME:-$HOME/.config}/panto/<name>` and --- `<cwd>/.panto/<name>`. A root whose base cannot be resolved (no HOME --- and no XDG_CONFIG_HOME) is simply omitted, so callers must not assume --- two entries. `walk` then collects `**/*.<suffix>` beneath a root. +-- first, as `<layer>/<name>` for every layer the host reports in +-- `panto.ext.dirs.layers` (base, user, project, local). The host owns +-- that list, so this file never reads HOME or the XDG variables itself; +-- a host that reports no layers yields no roots. `walk` then collects +-- `**/*.<suffix>` beneath a root. -- -- 2. The child session store. `child_store_dir()` derives -- `<session_dir>/subagents/<primary session id>` from the host's @@ -69,24 +70,19 @@ function M.read_file(path) return data end --- User layer first, project layer second: later roots shadow earlier ones. +-- One root per host layer, in the host's order: later roots shadow earlier +-- ones. A layer the host could not resolve is simply absent from its list. function M.config_roots(name) local roots = {} - local config_home = os.getenv("XDG_CONFIG_HOME") - if config_home == nil or config_home == "" then - local home = os.getenv("HOME") - if home ~= nil and home ~= "" then - config_home = home .. "/.config" - else - config_home = nil - end - end - if config_home ~= nil then - roots[#roots + 1] = config_home .. "/panto/" .. name + local ok, ext = pcall(host) + local layers = ok and type(ext) == "table" and ext.dirs and ext.dirs.layers + if type(layers) ~= "table" then + return roots end - local cwd = M.cwd() - if cwd ~= nil and cwd ~= "" then - roots[#roots + 1] = cwd .. "/.panto/" .. name + for _, layer in ipairs(layers) do + if type(layer) == "table" and type(layer.dir) == "string" and layer.dir ~= "" then + roots[#roots + 1] = layer.dir .. "/" .. name + end end return roots end diff --git a/subagents/profiles.lua b/subagents/profiles.lua index 4ae9f91..b1acdf3 100644 --- a/subagents/profiles.lua +++ b/subagents/profiles.lua @@ -1,13 +1,11 @@ -- Discover agent profiles: Markdown files with YAML frontmatter. -- --- Two layers are read, lowest precedence first: +-- One `agents/**/*.md` root per host config layer, lowest precedence first: +-- base, user, project, local (`panto.ext.dirs.layers`; see subagents/paths.lua). -- --- 1. ${XDG_CONFIG_HOME:-$HOME/.config}/panto/agents/**/*.md (user) --- 2. <cwd>/.panto/agents/**/*.md (project) --- --- A project profile shadows a user profile with the same resolved name. Both --- layers are walked recursively; nesting is organisational only and never part --- of a profile's name. +-- A later layer's profile shadows an earlier one with the same resolved name. +-- Every layer is walked recursively; nesting is organisational only and never +-- part of a profile's name. -- -- Recognised frontmatter keys, all optional: -- diff --git a/subagents/progress.lua b/subagents/progress.lua index 03fe62a..d88e239 100644 --- a/subagents/progress.lua +++ b/subagents/progress.lua @@ -326,8 +326,9 @@ end function M.settle(event) local key = type(event) == "table" and (event.id or event.tool_call_id) or nil local board = key and boards[key] - if not board then return end - set_board_pinned(board, false) + if board then set_board_pinned(board, false) end + local co = coroutine.running() + if co and bound[co] == key then bound[co] = nil end end function M.collapse(event) @@ -347,6 +348,12 @@ function M.bind(context) if co then bound[co] = key end end +function M.bind_coroutine(co, tool_call_id) + if type(co) ~= "thread" then return end + if type(tool_call_id) ~= "string" or tool_call_id == "" then tool_call_id = nil end + bound[co] = tool_call_id +end + -- The outer tool call is durable child-turn metadata, not model-visible output. -- Expose it to the spawn seam without making the board state global: workflow -- callbacks run on the same coroutine as their owning tool handler. @@ -723,7 +730,8 @@ function M.reset() set_board_pinned(board, false) end prune_boards() - bound = setmetatable({}, { __mode = "k" }) + -- `bound` has weak coroutine keys. Foreground handlers clear themselves in + -- settle(); background workflow bindings must survive turn boundaries. end return M diff --git a/subagents/run.lua b/subagents/run.lua index 375af91..7fbed67 100644 --- a/subagents/run.lua +++ b/subagents/run.lua @@ -1,9 +1,10 @@ -- The `subagents.run` tool: start one child agent, or continue one, and wait. -- --- One call handles both cases. `agent` starts a new child from that profile; --- `id` continues a child this primary session started earlier. Exactly one is --- required, and `prompt` is always required — a child cannot see the parent --- dialogue, so the prompt is the only task context it gets. +-- One call handles all cases. `agent` starts a new child from that profile; +-- `system_prompt` starts one without a saved profile; `id` continues a child +-- this primary session started earlier. Exactly one is required, and `prompt` +-- is always required — a child cannot see the parent dialogue, so the prompt +-- is the only task context it gets. -- -- The call blocks until the child settles. Parallelism is ordinary tool -- batching: several subagents.run calls emitted in one batch run concurrently diff --git a/subagents/spawn.lua b/subagents/spawn.lua index caf65aa..9117fdd 100644 --- a/subagents/spawn.lua +++ b/subagents/spawn.lua @@ -14,12 +14,14 @@ -- two fields resolve independently: a call may override reasoning while -- inheriting the model. -- --- A new child's conversation starts with the primary's effective system --- context, then the fixed child-role instruction, then — when the profile has --- a body — the profile prompt as a further system message. That profile --- message carries the immutable manifest metadata (owning primary session id + --- profile name); workflow-local profiles also carry an inline marker so the --- progress replay path can expose only prompts the caller explicitly supplied. +-- A new child's conversation starts with the fixed child-role instruction, +-- then — when the profile has a body — the profile prompt as a further system +-- message. The primary's system messages and dialogue are never copied: the +-- profile defines the child's system context rather than augmenting the +-- primary agent's prompt. That profile message carries the immutable manifest +-- metadata (owning primary session id + profile name); workflow-local profiles +-- also carry an inline marker so the progress replay path can expose only +-- prompts the caller explicitly supplied. -- The per-turn user metadata records the effective model/reasoning, the -- per-outer-call card sequence, terminal presentation status, and — when the -- spawn happened inside a bound extension tool — that outer tool call id. @@ -130,7 +132,7 @@ end -- build_spec(input, profiles) -> spec | nil, err -- --- input = { agent | id, prompt, model?, reasoning?, output? } +-- input = { agent | system_prompt | id, prompt, model?, reasoning?, output? } function M.build_spec(input, profiles) if type(input) ~= "table" then return nil, "expected a table of arguments" @@ -148,11 +150,14 @@ function M.build_spec(input, profiles) if err then return nil, err end - if agent and id then - return nil, "pass exactly one of `agent` (start a new child) or `id` (continue one), not both" + local system_prompt + system_prompt, err = optional_string(input.system_prompt, "system_prompt") + if err then + return nil, err end - if not agent and not id then - return nil, "pass exactly one of `agent` (start a new child) or `id` (continue one)" + local selectors = (agent and 1 or 0) + (system_prompt and 1 or 0) + (id and 1 or 0) + if selectors ~= 1 then + return nil, "pass exactly one of `agent` (start from a profile), `system_prompt` (start without a profile), or `id` (continue one)" end local model @@ -182,6 +187,12 @@ function M.build_spec(input, profiles) if not profile then return nil, string.format("unknown agent '%s'; known: %s", agent, M.agent_names(profiles)) end + elseif system_prompt then + profile = { + name = "subagent", + body = system_prompt, + inline = true, + } end -- child_store_dir returns the session info alongside the directory on @@ -208,17 +219,18 @@ function M.build_spec(input, profiles) spec.model = model or profile.model spec.reasoning = reasoning or profile.reasoning + local inline = profile.inline == true or profile.layer == "workflow" local system_messages = { { text = M.CHILD_ROLE } } if profile.body and profile.body:match("%S") then local manifest = { owner = info_or_err.session_id, agent = profile.name } - if profile.layer == "workflow" then manifest.inline = true end + if inline then manifest.inline = true end system_messages[#system_messages + 1] = { text = profile.body, metadata = { subagents = manifest }, } end spec.system_messages = system_messages - if profile.layer == "workflow" then + if inline then spec.presentation_system_prompt = profile.body end @@ -262,44 +274,6 @@ local function read_stored(conv) return defaults, manifest end --- The primary's effective system context, which every new child starts with. A --- replace-mode system block supersedes everything before it, exactly as the --- primary's own provider sees it. -local function primary_system_texts() - local primary = host().agent - if primary == nil then - return {} - end - local ok, conv = try(primary.conversation, primary) - if not ok or conv == nil then - return {} - end - local read, messages = try(conv.messages, conv) - if not read or type(messages) ~= "table" then - return {} - end - - local texts = {} - for _, message in ipairs(messages) do - if message.role == "system" then - local parts = {} - for _, block in ipairs(message.blocks or {}) do - if block.mode == "replace" then - texts, parts = {}, {} - end - if type(block.text) == "string" and (block.type == "system" or block.type == "text") then - parts[#parts + 1] = block.text - end - end - local text = table.concat(parts, "\n") - if text ~= "" then - texts[#texts + 1] = text - end - end - end - return texts -end - -- Everything the primary can call except the delegation tools themselves. The -- decls carry opaque source tags, so a child registering them reaches the same -- handlers on the same runtime. @@ -323,9 +297,6 @@ end local function seed_conversation(agent, spec) local conv = agent:conversation() - for _, text in ipairs(primary_system_texts()) do - conv:add_system_message(text) - end for _, message in ipairs(spec.system_messages or {}) do if message.metadata ~= nil then conv:add_system_message(message.text, { metadata = message.metadata }) @@ -527,6 +498,9 @@ function M.spawn(spec) pcall(agent.set_message_metadata, agent, turn_index, turn_metadata) end card:done(result.status, result.error) + if type(spec.on_settle) == "function" then + pcall(spec.on_settle, result) + end return result end diff --git a/subagents/toml_workflows.lua b/subagents/toml_workflows.lua index fba1930..0eb2c38 100644 --- a/subagents/toml_workflows.lua +++ b/subagents/toml_workflows.lua @@ -6,33 +6,33 @@ -- fan-out stay in the Lua API (subagents/workflow.lua); TOML deliberately does -- not grow into a programming language. -- --- Discovery mirrors profiles: `${XDG_CONFIG_HOME:-$HOME/.config}/panto/ --- workflows/**/*.toml` first, then `<cwd>/.panto/workflows/**/*.toml`, with the --- project layer shadowing the user layer by resolved name (the `name` field, --- defaulting to the file stem). +-- Discovery mirrors profiles: `workflows/**/*.toml` beneath every host config +-- layer (base, user, project, local), a later layer shadowing an earlier one by +-- resolved name (the `name` field, defaulting to the file stem). -- -- Validation runs before any inference: a workflow needs a non-empty `steps` -- array, every step needs a unique `id`, an `agent`, and a `prompt`, every -- entry in `needs` must name a declared step, and the dependency graph must be -- acyclic. Discovery itself never throws — an invalid file is recorded with its -- error and registers no command, and `subagents.workflow` reports that error --- if the model asks for the workflow by name. The same validator checks a --- transient `steps` definition passed straight to the tool. +-- if the model asks for the workflow by name. -- -- Execution lowers onto the Lua job primitives. Every step's prompt is its own -- text, then the workflow input, then one labeled section per dependency in -- `needs` order. All ready steps start at once; as each settles, any dependent -- whose needs are now satisfied starts immediately, so unrelated branches keep -- running. A step whose dependency did not complete is marked "skipped" and --- never spawns, and that skip cascades transitively. Terminal steps — those no --- other step depends on — are returned in declaration order, which keeps the --- output stable regardless of settle order. +-- never spawns, and that skip cascades transitively. What comes back is the +-- steps `output` names, in its order, or — without it — every terminal step +-- (one no other step depends on) in declaration order. Either way the reported +-- order is fixed by the file, not by settle order. -- -- Edge cases: the TOML parser returns nil rather than raising for some -- malformed documents, so a non-table parse result is treated as a parse -- error. A workflow input may legitimately be empty (a bare `/workflow:name` -- with no tail), which is passed through as an empty string rather than --- rejected. A workflow whose steps are all terminal returns every step. +-- rejected. A workflow whose steps are all terminal returns every step, unless +-- a top-level `output` array names the steps to report instead. local workflow = require("subagents.workflow") local paths = require("subagents.paths") @@ -86,7 +86,7 @@ end -- -- The returned definition is a fresh table, so a caller can trust its shape: -- { name, description, steps = { { id, agent, prompt, model, reasoning, --- needs }, ... }, terminal = { [id] = true } }. +-- needs }, ... }, terminal = { [id] = true }, report = { id, ... } }. function M.validate(def, fallback_name) if type(def) ~= "table" then return nil, "workflow definition must be a table" @@ -210,12 +210,42 @@ function M.validate(def, fallback_name) end end + -- What the workflow reports. `output` names the steps explicitly, in the + -- order it lists them; without it, every terminal step in declaration order. + local report = {} + if def.output ~= nil then + if not is_array(def.output) or #def.output == 0 then + return nil, string.format("workflow '%s': `output` must be a non-empty array of step ids", name) + end + local seen = {} + for _, id in ipairs(def.output) do + if type(id) ~= "string" or id == "" then + return nil, string.format("workflow '%s': `output` entries must be step ids", name) + end + if not by_id[id] then + return nil, string.format("workflow '%s': `output` names unknown step '%s'", name, id) + end + if seen[id] then + return nil, string.format("workflow '%s': `output` names '%s' twice", name, id) + end + seen[id] = true + report[#report + 1] = id + end + else + for _, step in ipairs(steps) do + if terminal[step.id] then + report[#report + 1] = step.id + end + end + end + return { name = name, description = description, steps = steps, by_id = by_id, terminal = terminal, + report = report, } end @@ -254,7 +284,7 @@ end -- discover() -> { list = ordered array, by_name = map, warnings = array } -- --- Later roots (the project layer) shadow earlier ones by resolved name. An +-- Later roots (the more local layers) shadow earlier ones by resolved name. An -- unreadable or invalid file never aborts discovery: it becomes a warning, and -- its name maps to a definition-less entry carrying the error so the tool can -- explain the failure if the model asks for it. An invalid file shadows under @@ -328,10 +358,16 @@ local function dependency_text(result) return "[failed: " .. tostring(result.error or result.status or "unknown") .. "]" end --- The exact prompt a step receives: its own text, the workflow input, then one --- labeled section per dependency in `needs` order. +-- The exact prompt a step receives: its own text, the workflow input when there +-- is one, then one labeled section per dependency in `needs` order. An +-- unparameterized workflow (a bare `/workflow:name`) gets no input heading +-- rather than an empty one. local function step_prompt(step, input, settled) - local parts = { step.prompt, "\n\n## Workflow input\n\n", input } + local parts = { step.prompt } + if input ~= nil and input ~= "" then + parts[#parts + 1] = "\n\n## Workflow input\n\n" + parts[#parts + 1] = input + end for _, need in ipairs(step.needs) do parts[#parts + 1] = "\n\n## Output of " .. need .. "\n\n" parts[#parts + 1] = dependency_text(settled[need]) @@ -383,6 +419,7 @@ function M.lower(def) elseif ready then table.remove(waiting, index) local handle = ctx:agent({ + name = step.id, agent = step.agent, prompt = step_prompt(step, input, settled), model = step.model, @@ -419,22 +456,20 @@ function M.lower(def) end local out = {} - for _, step in ipairs(def.steps) do - if def.terminal[step.id] then - local result = settled[step.id] or { status = "skipped", error = "skipped: never started" } - out[#out + 1] = { - id = step.id, - status = result.status, - output = result.output, - error = result.error, - } - end + for _, id in ipairs(def.report) do + local result = settled[id] or { status = "skipped", error = "skipped: never started" } + out[#out + 1] = { + id = id, + status = result.status, + output = result.output, + error = result.error, + } end return out end) end --- run(def, input, profiles) -> array of terminal results +-- run(def, input, profiles) -> array of reported results function M.run(def, input, profiles) return workflow.execute(M.lower(def), input or "", { profiles = profiles }) end @@ -454,7 +489,7 @@ end function M.format_results(results) if type(results) ~= "table" or #results == 0 then - return "The workflow produced no terminal results." + return "The workflow produced no results." end local blocks = {} for index, result in ipairs(results) do @@ -505,8 +540,8 @@ local function run_named(name, input, profiles) return M.format_results(results) end --- The `subagents.workflow` tool: run a discovered workflow by `name`, or a --- transient definition supplied as `steps`. Exactly one of the two. +-- The `subagents.workflow` tool: run a discovered workflow by `name`. Dynamic +-- graphs belong in `subagents.lua`, which is strictly more capable. function M.handle(input, profiles) if type(input) ~= "table" then return "Error: expected an input object" @@ -514,32 +549,27 @@ function M.handle(input, profiles) if type(input.prompt) ~= "string" or input.prompt == "" then return "Error: prompt is required and must be a non-empty string" end - - local has_name = input.name ~= nil - local has_steps = input.steps ~= nil - if has_name and has_steps then - return "Error: pass exactly one of `name` (a discovered workflow) or `steps` (a transient one), not both" - end - if not has_name and not has_steps then - return "Error: pass exactly one of `name` (a discovered workflow) or `steps` (a transient one)" - end - - if has_name then - if type(input.name) ~= "string" or input.name == "" then - return "Error: `name` must be a non-empty string" - end - return run_named(input.name, input.prompt, profiles) + if type(input.name) ~= "string" or input.name == "" then + return "Error: `name` is required and must be a non-empty string" end + return run_named(input.name, input.prompt, profiles) +end - local def, err = M.validate({ name = "transient", steps = input.steps }, "transient") - if not def then - return "Error: " .. tostring(err) +-- The first step naming a profile that was not discovered, if any. Agent names +-- cannot be checked by `validate` (it knows nothing about profiles), but they +-- can be checked here, once, rather than after a spawn has already burned the +-- tokens of every step that ran before the bad one. +local function missing_agent(def, profiles) + if type(profiles) ~= "table" or type(profiles.by_name) ~= "table" then + return nil end - local ok, results = pcall(M.run, def, input.prompt, profiles) - if not ok then - return "Error: " .. tostring(results) + for _, step in ipairs(def.steps) do + if profiles.by_name[step.agent] == nil then + return string.format("workflow '%s' step '%s': unknown agent '%s'", + def.name, step.id, step.agent) + end end - return M.format_results(results) + return nil end -- Discover the workflows and register a `/workflow:<name>` command for each @@ -549,6 +579,12 @@ function M.discover_and_register(profiles) registry = M.discover() local ext = host() for _, entry in ipairs(registry.list) do + local unknown = entry.definition and missing_agent(entry.definition, profiles) + if unknown then + entry.definition = nil + entry.error = unknown + registry.warnings[#registry.warnings + 1] = tostring(entry.path) .. ": " .. unknown + end if entry.definition then local def = entry.definition ext.register_command({ diff --git a/subagents/workflow.lua b/subagents/workflow.lua index 3cf58d9..3a59a8b 100644 --- a/subagents/workflow.lua +++ b/subagents/workflow.lua @@ -1,14 +1,13 @@ -- subagents/workflow.lua -- -- The callback-based Lua workflow API. `M.workflow(fn)` wraps a --- `function(ctx, input)` in a tagged table; `M.execute(wf, input, opts)` runs --- it and returns whatever the callback returned. Human-authored panto --- extensions require this module directly; the restricted `subagents.lua` tool --- (subagents/luatool.lua) and the TOML DAG lowering --- (subagents/toml_workflows.lua) are both built on the same primitives. +-- `function(ctx, input)` in a tagged table; `M.execute` runs it synchronously +-- on the caller's coroutine for trusted fixed DAGs, while `M.start` schedules +-- it on a dedicated coroutine and returns a session-scoped id for the +-- model-facing `subagents.lua` tool. Both forms use the same execution core. -- -- ctx surface: --- ctx:agent{ agent=, prompt=, model?, reasoning?, output? } -> handle +-- ctx:agent{ name=, agent|system_prompt|id=, prompt=, model?, reasoning?, output? } -> handle -- handle:await() -> one settled result -- ctx:await(handles, "all") -> array of settled results in input order -- ctx:await(handles, "first") -> first settled result, remaining handles @@ -26,13 +25,11 @@ -- pre-settled handle with status "failed" so a workflow can branch on it; -- execute() only raises for programmer/guest errors (bad arguments, an -- exceeded job budget, an error thrown by the callback itself). --- * The callback runs on the caller's own coroutine — the tool handler's — --- because `subagents.jobs.await` parks the running coroutine and resumes that --- exact coroutine from the uv callback that saw the child settle. Wrapping the --- callback in a nested coroutine would park the wrong thread and wedge the --- handler. opts.on_resume/opts.on_yield bracket the callback with that --- coroutine so a caller can arm a guard on it (the instruction budget the --- sandbox needs); trusted callers just omit them. +-- * `execute` runs on its caller's coroutine; `start` deliberately supplies a +-- dedicated coroutine whose lifetime is anchored by the workflow registry. +-- `subagents.jobs.await` parks and later resumes whichever coroutine invoked +-- it, so both paths use the same scheduler. opts.on_resume/opts.on_yield +-- bracket execution so the sandbox can arm its instruction guard. -- * "first" mode may settle several jobs at once. Extra results are cached on -- their handles rather than dropped, and a handle that already holds a -- result is served from that cache without re-entering the host, so no @@ -55,9 +52,49 @@ -- never matters. local spawn = require("subagents.spawn") +local progress = require("subagents.progress") + +local ok_uv, uv = pcall(require, "luv") local M = {} +-- Session-scoped background workflows. The model sees only immutable proxies; +-- mutable state and live handles stay private in this module. +local workflow_sequence = 0 +local workflow_records = {} +local active_workflows = {} + +local function readonly(index, pairs_fn, len_fn) + return setmetatable({}, { + __index = index, + __newindex = function() error("subagents workflow records are read-only", 2) end, + __pairs = pairs_fn, + __len = len_fn, + __metatable = false, + }) +end + +local workflows_proxy = readonly( + function(_, id) + local record = workflow_records[id] + return record and record.proxy or nil + end, + function() + local id + return function() + id = next(workflow_records, id) + local record = id and workflow_records[id] or nil + return id, record and record.proxy or nil + end + end, + function() + local count = 0 + for _ in pairs(workflow_records) do count = count + 1 end + return count + end) + +M.workflows = workflows_proxy + local workflow_mt = { __name = "subagents.workflow" } -- --------------------------------------------------------------------------- @@ -351,6 +388,40 @@ ctx_mt.__name = "subagents.ctx" -- exposing only `agent` and `await`. Weak keys so a finished run is collectable. local state = setmetatable({}, { __mode = "k" }) +local function make_agent_record(workflow_record, name) + local record = { + name = name, + status = "running", + output = nil, + error = nil, + id = nil, + } + record.proxy = readonly(function(_, key) + if key == "name" or key == "status" or key == "output" or key == "error" or key == "id" then + return record[key] + end + end) + workflow_record.agent_order[#workflow_record.agent_order + 1] = record + workflow_record.agents_by_name[name] = record + return record +end + +local function settle_agent_record(record, result) + if record == nil or type(result) ~= "table" then return end + record.status = result.status or "failed" + record.id = result.id + record.error = result.error + if result.output ~= nil then + record.output = M.output_text(result) + end +end + +local function settle_workflow_handle(handle, result) + handle.result = shape_result(result, handle) + settle_agent_record(handle.agent_record, handle.result) + return handle.result +end + -- The started job stays off the handle for the same reason: a subagents.jobs -- handle owns the child's agent and job userdata, so a guest holding a workflow -- handle would otherwise reach `agent:run_async` directly and start children @@ -365,15 +436,32 @@ function ctx_mt:agent(input) if not s then error("ctx:agent must be called on a workflow context (use ctx:agent{...})", 2) end + if s.record and s.record.cancel_requested then + error("workflow is cancelled", 2) + end if s.max_jobs and s.job_count >= s.max_jobs then error(string.format("workflow job limit exceeded (max %d)", s.max_jobs), 2) end + local name = input.name + if type(name) ~= "string" or name:match("^%s*$") then + error("ctx:agent requires a non-empty unique `name`", 2) + end + if s.agent_names[name] then + error("duplicate workflow agent name '" .. name .. "'", 2) + end + s.agent_names[name] = true + local agent_record = s.record and make_agent_record(s.record, name) or nil + -- spawn.build_spec owns profile lookup, model/reasoning precedence, the -- child-role system messages, and the synthetic output tool. A nil profile -- set means "use the cached discovery", which is what it already does. local spec, spec_err = spawn.build_spec(input, s.profiles) if not spec then + if agent_record then + agent_record.status = "failed" + agent_record.error = tostring(spec_err) + end error(tostring(spec_err), 2) end @@ -386,18 +474,22 @@ function ctx_mt:agent(input) ctx = self, spec = spec, output_schema = output_schema, + agent_record = agent_record, result = nil, }, handle_mt) + spec.on_settle = function(result) + if handle.result == nil then settle_workflow_handle(handle, result) end + end local job, job_err = spawn.spawn(spec) if not job then -- A rejected spawn is a child failure, not a workflow error. - handle.result = { + settle_workflow_handle(handle, { id = nil, status = "failed", error = tostring(job_err or "the host refused to start the child"), resumable = false, - } + }) else job_of[handle] = job end @@ -452,7 +544,7 @@ function ctx_mt:await(handles, mode) if #pending > 0 then local results = as_result_array(jobs().await(started, "all")) for index, handle in ipairs(pending) do - handle.result = shape_result(results[index], handle) + if handle.result == nil then settle_workflow_handle(handle, results[index]) end end end local out = {} @@ -493,8 +585,8 @@ function ctx_mt:await(handles, mode) end for index, result in ipairs(results) do local handle = settled[index] - if handle then - handle.result = shape_result(result, handle) + if handle and handle.result == nil then + settle_workflow_handle(handle, result) end end @@ -503,7 +595,7 @@ function ctx_mt:await(handles, mode) -- The await returned without settling anything; treat the batch as -- failed rather than spinning forever on the same handles. local first = pending[1] - first.result = copy_result(nil) + settle_workflow_handle(first, copy_result(nil)) return take_settled(handles) end return ready, remaining @@ -547,11 +639,9 @@ end -- on_resume -- called with the running coroutine before the callback starts -- on_yield -- called with the same coroutine once it has finished -- --- The callback runs on the CALLER's coroutine, never a nested one: --- `subagents.jobs.await` parks whichever coroutine is running when it suspends --- and resumes exactly that coroutine when the job settles. A nested coroutine --- would be the thread parked and resumed, leaving the tool handler that yielded --- around it suspended forever. +-- The callback runs on the caller's coroutine. `M.start` supplies a dedicated +-- one; direct callers use their own. `subagents.jobs.await` parks and resumes +-- that exact coroutine. function M.execute(wf, input, opts) if not M.is_workflow(wf) then error("subagents.workflow.execute expects a workflow object", 2) @@ -564,6 +654,8 @@ function M.execute(wf, input, opts) max_jobs = opts.max_jobs, job_count = 0, outstanding = {}, + agent_names = {}, + record = opts.record, } local co = coroutine.running() @@ -596,4 +688,124 @@ function M.output_text(result) return tostring(output) end +local function make_workflow_record(id) + local record = { + id = id, + status = "running", + result = nil, + error = nil, + agent_order = {}, + agents_by_name = {}, + cancel_requested = false, + suppress_notification = false, + } + record.agents_proxy = readonly( + function(_, key) + local agent = type(key) == "number" and record.agent_order[key] or record.agents_by_name[key] + return agent and agent.proxy or nil + end, + function() + local index = 0 + return function() + index = index + 1 + local agent = record.agent_order[index] + if agent then return agent.name, agent.proxy end + end + end, + function() return #record.agent_order end) + record.proxy = readonly(function(_, key) + if key == "id" or key == "status" or key == "result" or key == "error" then + return record[key] + elseif key == "agents" then + return record.agents_proxy + end + end) + return record +end + +local function notify(record) + if record.suppress_notification or record.status == "cancelled" then return end + local primary = host().agent + if primary == nil or type(primary.submit) ~= "function" then return end + local submitted = pcall(primary.submit, primary, string.format( + "[subagents] Workflow %s %s. Inspect subagents.workflows[%q] with subagents.lua.", + record.id, record.status, record.id)) + if submitted and type(host().emit) == "function" then + pcall(host().emit, "agent_submission") + end +end + +local function finish_workflow(record, status, result, err) + if record.status ~= "running" then return end + record.status = status + record.result = result + record.error = err + record.coroutine = nil + active_workflows[record.id] = nil + notify(record) +end + +-- Start a model-authored workflow on its own coroutine and return before the +-- callback runs. The existing jobs/luv machinery resumes that coroutine as +-- children settle; no second scheduler or thread is involved. +function M.start(wf, opts) + if not M.is_workflow(wf) then + error("subagents.workflow expects a function(ctx)", 2) + end + if not ok_uv or type(uv.new_timer) ~= "function" then + error("subagents.workflow requires luv", 2) + end + opts = opts or {} + + workflow_sequence = workflow_sequence + 1 + local id = "workflow-" .. workflow_sequence + local record = make_workflow_record(id) + workflow_records[id] = record + active_workflows[id] = record + + local co = coroutine.create(function() + if record.cancel_requested then + return finish_workflow(record, "cancelled", nil, "workflow cancelled") + end + local ok, result = pcall(M.execute, wf, nil, { + max_jobs = opts.max_jobs, + profiles = opts.profiles, + record = record, + on_resume = opts.on_resume, + on_yield = opts.on_yield, + }) + if record.cancel_requested then + finish_workflow(record, "cancelled", nil, "workflow cancelled") + elseif not ok then + finish_workflow(record, "failed", nil, tostring(result)) + elseif type(result) ~= "string" then + finish_workflow(record, "failed", nil, "workflow callback must return a string") + else + finish_workflow(record, "completed", result, nil) + end + end) + record.coroutine = co + progress.bind_coroutine(co, opts.tool_call_id) + + local timer = uv.new_timer() + record.timer = timer + timer:start(0, 0, function() + timer:stop() + timer:close() + record.timer = nil + local ok, err = coroutine.resume(co) + if not ok then + finish_workflow(record, record.cancel_requested and "cancelled" or "failed", nil, tostring(err)) + end + end) + return id +end + +function M.cancel_all(suppress_notification) + for _, record in pairs(active_workflows) do + record.cancel_requested = true + if suppress_notification then record.suppress_notification = true end + end +end + return M |
