summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.md77
-rw-r--r--e2e/run.lua83
-rw-r--r--init.lua165
-rw-r--r--panto-subagents-0.1.0-1.rockspec5
-rw-r--r--spec/fake_ext.lua33
-rw-r--r--spec/test_init.lua121
-rw-r--r--spec/test_jobs.lua36
-rw-r--r--spec/test_luatool.lua330
-rw-r--r--spec/test_models.lua49
-rw-r--r--spec/test_profiles.lua16
-rw-r--r--spec/test_progress_replay.lua23
-rw-r--r--spec/test_run.lua42
-rw-r--r--spec/test_toml_workflows.lua161
-rw-r--r--spec/test_workflow.lua41
-rw-r--r--subagents/jobs.lua35
-rw-r--r--subagents/luatool.lua80
-rw-r--r--subagents/models.lua56
-rw-r--r--subagents/paths.lua36
-rw-r--r--subagents/profiles.lua12
-rw-r--r--subagents/progress.lua14
-rw-r--r--subagents/run.lua9
-rw-r--r--subagents/spawn.lua82
-rw-r--r--subagents/toml_workflows.lua138
-rw-r--r--subagents/workflow.lua260
24 files changed, 1212 insertions, 692 deletions
diff --git a/README.md b/README.md
index 73a3146..aadf719 100644
--- a/README.md
+++ b/README.md
@@ -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'")
diff --git a/init.lua b/init.lua
index a104324..2ec5f8c 100644
--- a/init.lua
+++ b/init.lua
@@ -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