summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore2
-rw-r--r--README.md39
-rw-r--r--REFACTOR.md196
-rw-r--r--init.lua213
-rw-r--r--mise.toml38
-rw-r--r--panto-subagents-0.1.0-1.rockspec84
-rw-r--r--spec/fake_ext.lua709
-rw-r--r--spec/run.lua81
-rw-r--r--spec/test_frontmatter.lua88
-rw-r--r--spec/test_init.lua180
-rw-r--r--spec/test_jobs.lua244
-rw-r--r--spec/test_luatool.lua188
-rw-r--r--spec/test_models.lua158
-rw-r--r--spec/test_profiles.lua138
-rw-r--r--spec/test_run.lua332
-rw-r--r--spec/test_toml_workflows.lua404
-rw-r--r--spec/test_workflow.lua299
-rw-r--r--subagents/frontmatter.lua96
-rw-r--r--subagents/jobs.lua427
-rw-r--r--subagents/luatool.lua193
-rw-r--r--subagents/models.lua233
-rw-r--r--subagents/paths.lua169
-rw-r--r--subagents/profiles.lua122
-rw-r--r--subagents/progress.lua260
-rw-r--r--subagents/run.lua80
-rw-r--r--subagents/spawn.lua523
-rw-r--r--subagents/toml_workflows.lua570
-rw-r--r--subagents/workflow.lua614
28 files changed, 6680 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..85c89f9
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+# Local luarocks tree created by `mise run deps`.
+.rocks/
diff --git a/README.md b/README.md
index 8c24c36..713ea60 100644
--- a/README.md
+++ b/README.md
@@ -13,6 +13,22 @@ rocks = ["panto-subagents"]
For a local checkout, use `paths = ["/path/to/panto-subagents"]` instead.
+### Dependencies
+
+Panto installs the rock's dependencies with it:
+
+- **lyaml** parses profile frontmatter. It binds the system libyaml, which
+ 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.
+- **luv** backs profile and workflow discovery. Panto already ships it.
+
+Structured workflow output is validated against its JSON Schema by a built-in
+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.
+
## Agent profiles
Agents are Markdown files with YAML frontmatter:
@@ -87,4 +103,27 @@ their structured result. Panto extensions can use this API directly, while
`subagents.lua` runs model-authored one-off workflows in a restricted Lua
environment.
+## Development
+
+[mise](https://mise.jdx.dev) provides Lua 5.4 and LuaRocks:
+
+```sh
+mise run deps # install the rocks into ./.rocks (gitignored)
+mise run check # run the specs against that tree
+```
+
+`mise run deps` passes Homebrew's libyaml prefix to lyaml when brew is
+available. Without mise, install the same rocks with any LuaRocks targeting Lua
+5.4 and run `lua spec/run.lua` from the repo root. `panto lua spec/run.lua` runs
+the suite inside panto's own interpreter and rocks tree, skipping whatever cases
+that tree has no rock for.
+
+The specs are plain Lua asserts — no framework. `spec/run.lua` loads every
+`spec/test_*.lua`, each of which returns an ordered array of `{ name, function }`
+cases. A case asserts and returns nothing to pass, or returns `"skip", reason`
+when an optional rock is missing; skips do not fail the run. `spec/fake_ext.lua`
+is a scriptable stand-in for the `panto.ext` host seam: it queues the result each
+spawned child settles with, records every spawn spec, tool, and command the
+extension produced, and settles awaits synchronously.
+
See [DESIGN.md](DESIGN.md) for the runtime and persistence design.
diff --git a/REFACTOR.md b/REFACTOR.md
new file mode 100644
index 0000000..3eec504
--- /dev/null
+++ b/REFACTOR.md
@@ -0,0 +1,196 @@
+# panto-subagents refactor: extension-API-first
+
+## Goal
+
+The v0 implementation works end to end, but pantograph absorbed the feature:
+`subagent_host.zig`, `subagent_runtime.zig`, and `tui_subagents.zig` are a
+subagent backend in core, and the rock is mostly validation and formatting
+glue. Per the "Extension features: two responses" rubric in pantograph's
+AGENTS.md, this refactor takes response 2 retroactively: the missing *general*
+capabilities are added to libpanto-lua and `panto.ext`, subagent policy moves
+into this rock, and the feature-shaped core surface is deleted.
+
+Definition of done, in one line: `rg -i subagent pantograph/src` returns
+nothing, and every behavior in DESIGN.md still holds through the real binary.
+
+The product surface (DESIGN.md) is unchanged. This is a re-division of the
+implementation, not a redesign. DESIGN.md's "Generic pantograph child-job
+seam" section is superseded by this document and should be rewritten to match
+when this lands.
+
+## What exists today (all currently uncommitted working-tree state)
+
+Generic and staying (invisible runtime correctness, landed with v0):
+
+- The multiplexed Lua executor: tool batches keyed by id instead of a single
+ `current_batch`, cross-thread posted jobs onto the Lua owner thread
+ (`lua_executor.zig`, `lua_runtime.zig`).
+- Lane identity: `panto.ext.agent` resolves through `coroutine.running()`;
+ per-open protocol stream state and stream-level `cancel_turn`
+ (`lua_provider.zig`).
+- libpantograph: message metadata survives the JSONL load path;
+ `UserMessage.metadata` exists on `Agent.run`.
+- `panto.ext.models` (bounded catalog queries) and `panto.ext.session_info()`.
+
+Feature-shaped and going: `subagent_host.zig` (vtable), `subagent_runtime.zig`
+(spawn transaction, worker threads, `Gate` concurrency bound, metadata
+stamping, manifest extraction, resume defaults, one-shot workers, progress
+queue), `tui_subagents.zig` (hardcoded progress panel), and the
+`spawn_agent`/`await_jobs` thunks in `lua_bridge.zig`.
+
+## New libpanto-lua APIs
+
+These are library bindings, useful to any embedder of libpanto-lua; nothing
+about them is subagent-specific.
+
+### Async agent jobs
+
+```lua
+local job = agent:run_async {
+ prompt = "...", -- or blocks, mirroring agent:run
+ metadata = { ... }, -- optional user-message metadata (JSON-encoded)
+ dispatch_tools = false, -- optional: end the turn at the first
+ -- assistant response instead of dispatching
+ -- its tool calls; they are reported settled
+ wake_fd = fd, -- optional: a byte is written on every event
+ -- arrival and on settle
+}
+
+job:next_event() -- -> event table | nil (non-blocking; owned copy)
+job:result() -- -> settled result | nil while running
+job:request_cancel()
+job:close() -- join + release; safe after settle or cancel
+```
+
+- Implemented with a binding-internal pump thread per job: it drives the
+ blocking `stream.next()`, copies each event payload (payloads die at the
+ next pull), buffers under a mutex, and writes one byte to `wake_fd`.
+- The fd contract is deliberately loop-agnostic: a luv consumer arms
+ `uv.new_poll(fd)`; a Go or C embedder selects on it. No loop dependency
+ enters libpanto-lua.
+- Tool dispatch inside the turn is unchanged libpantograph behavior; when the
+ agent's tools include panto's Lua tool source, its `invoke_batch` posts to
+ the Lua owner thread through the existing executor — that path already
+ exists and does not change.
+- `dispatch_tools = false` is the generic knob that makes structured one-shot
+ workers a pure consumer feature: the settled result carries the assistant's
+ tool calls (name + input JSON) unresolved, and no second model round runs.
+
+### Conversation message metadata
+
+```lua
+conv:message_metadata(i) -- -> table | nil (decoded JSON)
+conv:set_message_metadata(i, tbl) -- JSON-encodes; nil clears
+```
+
+Plus optional `metadata` on `conv:add_system_message`/`add_user_message`.
+The disk format already round-trips metadata; this is access only.
+
+### Agent tool control
+
+```lua
+agent:tools() -- -> array of decl tables { name, description, schema },
+ -- a mutable copy
+agent:set_tools(decls) -- replace this agent's tool list with these decls;
+ -- handlers resolve by name through the owning source
+```
+
+Copying between agents works because dispatch is name-keyed on the shared
+runtime. `agent:set_config` additionally accepts `tool_choice` (force a named
+tool). This retires the Zig-side `toolSourceExcluding`.
+
+## New/changed pantograph surfaces
+
+- `panto.ext.resolve_model { model = "provider:alias", reasoning = label }`
+ → an **opaque config userdata** accepted by `panto.agent { config = ... }`
+ and `agent:set_config`. Resolution and validation reuse the existing
+ registry/reasoning pipeline and fail before inference; credentials are
+ embedded host-side and never readable from Lua. This is the one place the
+ credential boundary requires a host API rather than a binding API.
+- `panto.ext.models` and `panto.ext.session_info()` remain as-is (already
+ generic).
+- `spawn_agent`, `await_jobs`, the job-handle userdata, and the
+ `SubagentHost` vtable are deleted once the rock is ported. Awaiting becomes
+ ordinary luv: the rock arms `uv.new_poll` on each job's `wake_fd` and its
+ tool-handler coroutine yields/resumes through the standard async-tool
+ contract.
+- Progress rendering: `tui_subagents.zig` is deleted. The rock pulls its own
+ job events and surfaces activity through the existing extension event and
+ component machinery (`panto.ext.on`/`emit` + component swap). If that
+ machinery proves insufficient for a live multi-card panel, the fallback is
+ one small *generic* host API (a "live status card" keyed by an id — second
+ consumer: any long-running extension activity), to be co-designed before
+ building, per AGENTS.md.
+
+## Policy migration map (the closure check)
+
+Every deleted Zig behavior must have a Lua expression using only the APIs
+above. This table is the completeness test for the API set:
+
+| Behavior (today in Zig) | New home in the rock |
+|------------------------------------------|----------------------|
+| Child store layout `<sd>/subagents/<id>` | string assembly (already rock-side) |
+| Store create/resolve, ownership boundary | `panto.file_system_jsonl_store(dir)` + `store:resolve/load` |
+| Manifest on profile system message | `conv:set_message_metadata` at seeding |
+| Per-turn model/reasoning stamp | `run_async { metadata = ... }` |
+| Resume defaults from last user message | `store:load` + `conv:message_metadata` scan |
+| Primary system context for new children | copy system messages from `panto.ext.agent:conversation()` |
+| Concurrency bound (4) + queue + cancel-while-queued | rock-side gate: start ≤4 jobs, queue the rest, drop queued on cancel (~15 lines of Lua) |
+| `subagents.*` exclusion (no recursion) | `agent:set_tools(filtered)` from the primary's `agent:tools()` |
+| One-shot structured workers | new agent + single synthetic tool + `tool_choice` + `dispatch_tools = false` + `panto.null_store()`; validate with the existing jsonschema path |
+| `resumable` determination | fresh `store:resolve(id)` after failure |
+| Result shaping (id/agent/status/output) | already rock-side; loses the host `JobResult` intermediary |
+| Progress cards | rock consumes `job:next_event()`; renders via event/component machinery |
+| Escape cancels all children | rock tracks its live jobs; a host cancellation event (or the turn-teardown hook that fires it) calls `job:request_cancel()` on each — verify the existing bus exposes interrupt; if not, add a generic turn-lifecycle event |
+
+Two rows need small verifications during implementation (marked above):
+turn-lifecycle/interrupt visibility to extensions, and whether the component
+machinery can host the live panel. Both resolutions must stay feature-neutral.
+
+## Deletions
+
+pantograph: `subagent_host.zig`, `subagent_runtime.zig`, `tui_subagents.zig`,
+the spawn/await thunks and job userdata in `lua_bridge.zig`, the subagent
+wiring in `main.zig`/`tui_app.zig`, and `subagent_integration_tests.zig` in
+its current form (see validation). The executor, lane machinery, and
+`lua_provider` lane calls remain as internal runtime correctness — reviewed
+afterward for members only the deleted code used.
+
+## Validation
+
+- The v0 end-to-end proof must survive: a scripted extension-protocol fixture
+ driving the real binary with the real rock — parallel `subagents.run` +
+ unknown-agent error in one batch, child resume with history, workflow
+ diamond, sandbox fan-out, catalog round-trip, child JSONL
+ isolation/manifest/metadata asserted from bytes on disk. (Reconstruct the
+ harness from this list; treat it as the primary gate.)
+- Rock suite (`mise run check`) extended to cover the migrated policy:
+ gating/bound behavior, resume-default extraction, one-shot capture via
+ unresolved tool calls, tool filtering, manifest seeding.
+- pantograph integration tests rewritten against the *generic* surfaces
+ (async jobs driving concurrent agents, batch isolation, lane agent
+ resolution, cancellation) with no subagent vocabulary.
+- `rg -i subagent /Users/travis/Code/pantograph/src` → empty.
+- All suites green: `mise exec -- zig build test`, `mise run check`.
+
+## Delivery order
+
+1. libpanto-lua: `run_async` jobs + fd contract + `dispatch_tools` knob, with
+ binding-level tests (fixture provider, no panto involvement).
+2. libpanto-lua: message-metadata access; agent tool get/set + `tool_choice`.
+3. pantograph: `resolve_model` opaque config; verify interrupt/lifecycle
+ event visibility (add the generic event if missing).
+4. Rock: port spawn/await/bound/policy onto the new APIs behind the same tool
+ surfaces; port progress to event/component rendering.
+5. Delete the pantograph subagent files and thunks; rewrite integration tests
+ generically; run the full validation list.
+6. Update DESIGN.md's seam sections to describe the final division.
+
+## Interop with the panto-on-libuv project (separate; order-independent)
+
+The `wake_fd` contract works identically in both worlds: today the rock arms
+it with `uv.new_poll` on luv's loop; after the libuv unification it is the
+same call on the shared process loop. If the libuv project lands first, the
+executor internals this refactor relies on (posted jobs onto the Lua owner)
+will already use `uv_async` instead of the pipe waker — no change to anything
+specified here. Neither project should block on the other.
diff --git a/init.lua b/init.lua
new file mode 100644
index 0000000..5f28308
--- /dev/null
+++ b/init.lua
@@ -0,0 +1,213 @@
+-- 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.
+--
+-- Discovery order is deterministic and side-effect free apart from
+-- registration, because pantograph evaluates every candidate extension file
+-- but only activates the ones its allow/deny policy permits.
+--
+-- The discovered profile set is captured here and threaded into the run and
+-- models handlers, so both tools describe and resolve exactly the profiles
+-- named in the tool description the model was shown. Profile warnings (a
+-- foreign `model` spelling, a duplicate name in one layer) have no logging
+-- channel in an extension, so a bounded number of them ride along at the end
+-- 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 drops the
+-- progress cards, which are presentation state that must not survive the turn
+-- that produced it.
+--
+-- If the host predates the model-resolution seam, activation fails loudly
+-- instead of registering tools that cannot work.
+
+local jobs = require("subagents.jobs")
+local luatool = require("subagents.luatool")
+local models = require("subagents.models")
+local progress = require("subagents.progress")
+local run = require("subagents.run")
+local spawn = require("subagents.spawn")
+local toml_workflows = require("subagents.toml_workflows")
+
+local MAX_SHOWN_WARNINGS = 5
+
+local function host()
+ return require("panto").ext
+end
+
+-- Events are optional: a host without the bus, or a print-mode session with no
+-- components, simply never calls back and every child still runs.
+local function subscribe(ext, name, handler)
+ if type(ext.on) == "function" then
+ pcall(ext.on, name, handler)
+ end
+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.",
+ "",
+ "Agent profiles:",
+ }
+ if #profiles.list == 0 then
+ lines[#lines + 1] = " (none found; define them in .panto/agents/*.md)"
+ else
+ for _, profile in ipairs(profiles.list) do
+ if profile.description ~= "" then
+ lines[#lines + 1] = string.format(" %s — %s", profile.name, profile.description)
+ else
+ lines[#lines + 1] = " " .. profile.name
+ end
+ end
+ end
+
+ 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] = "- 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."
+ lines[#lines + 1] = "- Every started child reports an id. Pass that id back to continue the same conversation."
+
+ if #profiles.warnings > 0 then
+ lines[#lines + 1] = ""
+ lines[#lines + 1] = "Profile warnings:"
+ for index, warning in ipairs(profiles.warnings) do
+ if index > MAX_SHOWN_WARNINGS then
+ lines[#lines + 1] = string.format(" (%d more)", #profiles.warnings - MAX_SHOWN_WARNINGS)
+ break
+ end
+ lines[#lines + 1] = " " .. warning
+ end
+ end
+
+ return table.concat(lines, "\n")
+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
+
+ -- Discover through spawn so the workflow lanes, which resolve profiles
+ -- lazily, share the exact set this tool description advertises.
+ local profiles = spawn.profiles()
+
+ -- Escape reaches the children through the turn, not through a tool: the
+ -- primary is parked inside a tool call when they are running.
+ subscribe(ext, "turn_interrupt", function()
+ jobs.cancel_all()
+ end)
+ subscribe(ext, "turn_end", function()
+ jobs.close_all()
+ progress.reset()
+ end)
+ -- The entry for a delegation call is where that call's children render.
+ subscribe(ext, "tool_call_complete", function(event)
+ progress.claim(event)
+ end)
+
+ ext.register_tool {
+ name = "subagents.run",
+ description = run_description(profiles),
+ 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`." },
+ 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." },
+ reasoning = { type = "string", description = "Optional reasoning level override. Omit to inherit." },
+ },
+ required = { "prompt" },
+ },
+ handler = function(input, context)
+ progress.bind(context)
+ return run.handle(input, profiles)
+ end,
+ }
+
+ 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.",
+ schema = {
+ type = "object",
+ properties = {
+ provider = { type = "string", description = "Restrict a search to one provider." },
+ 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)
+ 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.",
+ 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)." },
+ },
+ required = { "prompt", "source" },
+ },
+ handler = function(input, context)
+ progress.bind(context)
+ return luatool.handle(input, profiles)
+ end,
+ }
+
+ 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.",
+ schema = {
+ type = "object",
+ properties = {
+ name = { type = "string", description = "Name of a discovered workflow. Mutually exclusive with `steps`." },
+ 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" },
+ },
+ handler = function(input, context)
+ progress.bind(context)
+ return toml_workflows.handle(input, profiles)
+ end,
+ }
+
+ toml_workflows.discover_and_register(profiles)
+end
+
+return {
+ name = "subagents",
+ activate = activate,
+}
diff --git a/mise.toml b/mise.toml
new file mode 100644
index 0000000..429d8dd
--- /dev/null
+++ b/mise.toml
@@ -0,0 +1,38 @@
+[tools]
+lua = "5.4"
+
+# `mise run deps` installs the rocks into ./.rocks (gitignored); `mise run check`
+# puts that tree on package.path and runs the specs. Both pin --lua-version 5.4
+# because a stray system luarocks may default to another Lua.
+#
+# lyaml is a C binding over libyaml and needs the system library present:
+# `brew install libyaml` on macOS, `apt install libyaml-dev` on Debian. The
+# YAML_DIR below points luarocks at Homebrew's prefix when brew is available and
+# otherwise lets luarocks search the usual system paths.
+
+[tasks.deps]
+description = "Install the Lua rocks the extension and specs need into ./.rocks"
+dir = "{{config_root}}"
+run = """
+set -e
+if brew --prefix libyaml >/dev/null 2>&1; then
+ luarocks --lua-version 5.4 --tree .rocks install lyaml YAML_DIR="$(brew --prefix libyaml)"
+else
+ luarocks --lua-version 5.4 --tree .rocks install lyaml
+fi
+luarocks --lua-version 5.4 --tree .rocks install toml2lua
+luarocks --lua-version 5.4 --tree .rocks install luv
+luarocks --lua-version 5.4 --tree .rocks install dkjson
+"""
+
+[tasks.check]
+description = "Run the specs against ./.rocks"
+dir = "{{config_root}}"
+run = """
+set -e
+# Without the tree the specs still pass, but every case that needs a rock skips
+# itself; install once rather than reporting a green run that tested less.
+[ -d .rocks ] || mise run deps
+eval "$(luarocks --lua-version 5.4 --tree .rocks path)"
+lua spec/run.lua
+"""
diff --git a/panto-subagents-0.1.0-1.rockspec b/panto-subagents-0.1.0-1.rockspec
new file mode 100644
index 0000000..073ff56
--- /dev/null
+++ b/panto-subagents-0.1.0-1.rockspec
@@ -0,0 +1,84 @@
+-- LuaRocks rockspec for `panto-subagents`: the `subagents` extension for
+-- Pantograph — pure Lua, no compiled artifact of its own.
+--
+-- Module naming. Pantograph requires the first whitespace-delimited token of an
+-- `[extensions] rocks = [...]` entry, so `require("panto-subagents")` must
+-- resolve to this rock's entry point; that is what the first `modules` line
+-- maps. The remaining modules keep the same `subagents.<name>` paths a local
+-- checkout gets from `paths = ["/path/to/panto-subagents"]`, so both load paths
+-- resolve identical requires.
+--
+-- 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
+-- 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.
+--
+-- Deliberately absent: `jsonschema`. It validates structured workflow output
+-- when installed, and subagents/workflow.lua uses it if `require` finds it, but
+-- it depends on `lrexlib-pcre`, which needs a system PCRE that stock macOS does
+-- not have. A failed dependency install would take the whole extension down
+-- silently (panto logs and skips a rock that fails to load), so the built-in
+-- subset validator is the default and `jsonschema` stays opt-in.
+
+rockspec_format = "3.0"
+package = "panto-subagents"
+version = "0.1.0-1"
+
+source = {
+ url = "git+https://github.com/travisp/panto-subagents",
+}
+
+description = {
+ summary = "Delegation and workflows for Pantograph: the `subagents` extension.",
+ detailed = [[
+ Lets a primary panto agent start specialized child agents from Markdown
+ profiles, run several at once, and continue their conversations later.
+ Provides the subagents.run / subagents.models / subagents.lua /
+ subagents.workflow tools, a callback-based Lua workflow API with
+ one-shot structured workers, and TOML dependency graphs exposed as
+ /workflow:<name> commands.
+ ]],
+ homepage = "https://github.com/travisp/panto-subagents",
+ license = "MIT",
+ labels = { "ai", "llm", "agent", "panto", "pantograph", "extension" },
+}
+
+dependencies = {
+ "lua >= 5.4, < 5.5",
+ "lyaml >= 6.2, < 7.0",
+ "toml2lua >= 3.0, < 4.0",
+ "luv >= 1.48",
+}
+
+-- The specs run on plain Lua and decode structured child output with dkjson
+-- when the host's own JSON codec (`panto.ext.json`) is absent.
+test_dependencies = {
+ "dkjson >= 2.11",
+}
+
+test = {
+ type = "command",
+ command = "lua spec/run.lua",
+}
+
+build = {
+ type = "builtin",
+ modules = {
+ ["panto-subagents"] = "init.lua",
+ ["subagents.frontmatter"] = "subagents/frontmatter.lua",
+ ["subagents.jobs"] = "subagents/jobs.lua",
+ ["subagents.luatool"] = "subagents/luatool.lua",
+ ["subagents.models"] = "subagents/models.lua",
+ ["subagents.paths"] = "subagents/paths.lua",
+ ["subagents.profiles"] = "subagents/profiles.lua",
+ ["subagents.progress"] = "subagents/progress.lua",
+ ["subagents.run"] = "subagents/run.lua",
+ ["subagents.spawn"] = "subagents/spawn.lua",
+ ["subagents.toml_workflows"] = "subagents/toml_workflows.lua",
+ ["subagents.workflow"] = "subagents/workflow.lua",
+ },
+}
diff --git a/spec/fake_ext.lua b/spec/fake_ext.lua
new file mode 100644
index 0000000..5ec4c06
--- /dev/null
+++ b/spec/fake_ext.lua
@@ -0,0 +1,709 @@
+-- A scriptable stand-in for the `panto` module the extension talks to.
+--
+-- Every module reaches the host through `require("panto")` at call time, never
+-- as a load-time alias, so a spec can install this fake after the modules are
+-- already loaded. `install()` writes `package.loaded.panto`; `handle.restore()`
+-- removes it again.
+--
+-- The surface mirrors the real one, covering what the extension actually calls:
+--
+-- panto.ext.session_info() -> { session_id, session_dir, model, reasoning }
+-- panto.ext.resolve_model(q) -> cfg | nil, "resolve_model: ..."
+-- panto.ext.models(query) -> catalog answer
+-- panto.ext.agent -- the primary, borrowed: :tools(), :conversation()
+-- panto.ext.on(name, handler) -- lifecycle subscriptions
+-- panto.ext.register_tool / register_command
+-- panto.ext.json -- panto's JSON codec (dkjson stands in here)
+-- panto.agent{ config =, store =, session_id =, conversation = } -> agent
+-- panto.file_system_jsonl_store{ dir = } / panto.null_store()
+-- agent:conversation() / :session_id() / :tools() / :set_tools(decls) / :run_async(opts)
+-- store:resolve(id) / :load(id)
+-- conv:messages() / :message_metadata(i) / :add_system_message(text, { metadata = })
+-- job:next_event() / :result() / :request_cancel() / :close()
+--
+-- Scripting. Every child takes its outcome from a queue: `handle.queue(outcome)`
+-- is matched first-in-first-out, `handle.queue_for(label, outcome)` is matched
+-- against the profile name the child was seeded with (its manifest metadata; a
+-- resumed child carries no fresh manifest, so it always draws from the FIFO
+-- queue). An outcome describes the settled turn — `{ status =, output =,
+-- error =, structured_json =, resumable =, id =, settle = }`. A child with
+-- nothing queued completes with a generated id and a generic output, which
+-- keeps tests that only care about what was asked for short.
+--
+-- Settling. There is no event loop here, so a fake job settles on the Nth call
+-- to `job:result()`: `settle = N`, lowest first, which is the same ordering key
+-- the previous fake used for "first" awaits. `uv.pipe()` fails in this harness
+-- (see the stub below), so the job machinery takes its fd-less path and drains
+-- its jobs in place; that is what turns those polls into progress and keeps the
+-- specs plain assert scripts. `request_cancel` settles the next poll as
+-- cancelled, exactly like the binding's pump.
+--
+-- Everything the host was asked to do is recorded on the returned handle:
+-- `spawns` (one record per child agent, in creation order, carrying the
+-- resolve_model arguments, the store directory, the seeded system messages, the
+-- tool declarations it was given and the run_async options), `runs` (run_async
+-- options only, so a gated child is visibly not started yet), `jobs`,
+-- `resolves`, `tools`, `commands`, `models_queries`, `subscriptions`, `mkdirs`,
+-- `max_live` and `polls`. Assertions read those tables directly.
+
+local M = {}
+
+-- ---------------------------------------------------------------------------
+-- luv stand-in, installed once when this module loads
+-- ---------------------------------------------------------------------------
+
+-- The harness has no event loop, so it has no pipes: `uv.pipe()` fails and the
+-- job machinery falls back to draining in place, which is the same path a host
+-- without luv takes. `fs_mkdir` is recorded rather than performed, so a spec can
+-- assert which directories a child store would need without touching the disk.
+-- Everything else delegates to the real luv, so the filesystem cases are
+-- unaffected. This has to happen at load time, not inside install(), because a
+-- module that resolves luv once at load would otherwise capture the real one.
+local made_dirs = {}
+do
+ local ok, real = pcall(require, "luv")
+ if ok and type(real) == "table" then
+ package.loaded.luv = setmetatable({
+ pipe = function()
+ return nil, "the spec harness provides no pipes"
+ end,
+ fs_mkdir = function(path)
+ made_dirs[#made_dirs + 1] = path
+ return true
+ end,
+ }, { __index = real })
+ end
+end
+
+-- A job polled this many times without settling means the drain loop is
+-- spinning; failing beats hanging the whole suite.
+local POLL_LIMIT = 5000
+
+local DEFAULT_SESSION = {
+ session_id = "0198-primary",
+ session_dir = "/tmp/panto-spec-sessions/--Users-travis-Code-panto-subagents--",
+ model = "anthropic:sonnet",
+ reasoning = "medium",
+}
+
+local DEFAULT_OVERVIEW = {
+ model = "anthropic:sonnet",
+ reasoning = "medium",
+ providers = {
+ { name = "anthropic", style = "messages", models = 7 },
+ { name = "openai", style = "responses", models = 12 },
+ },
+}
+
+-- Stands in for the lightuserdata re-registration tag a real decl carries. The
+-- identity is what matters: a child must be given the primary's own tags.
+M.SOURCE = setmetatable({}, { __name = "fake.tool_source" })
+
+-- The primary's tools: two a child inherits, and the four it must not.
+-- agent:tools() sorts by name, so this is the order the rock sees them in.
+local DEFAULT_TOOL_NAMES = {
+ "bash",
+ "read_file",
+ "subagents.lua",
+ "subagents.models",
+ "subagents.run",
+ "subagents.workflow",
+}
+
+local function default_tools()
+ local decls = {}
+ for index, name in ipairs(DEFAULT_TOOL_NAMES) do
+ decls[index] = {
+ name = name,
+ description = name .. " does a thing",
+ schema = { type = "object", properties = {} },
+ _source = M.SOURCE,
+ }
+ end
+ return decls
+end
+
+-- panto installs its own JSON codec as `panto.ext.json`; dkjson is the closest
+-- stand-in available to a bare `lua` process. Absent, the field is simply
+-- missing, exactly as it would be on a host too old to provide it.
+local function json_codec()
+ local ok, dkjson = pcall(require, "dkjson")
+ if not ok or type(dkjson) ~= "table" then
+ return nil
+ end
+ return {
+ encode = function(value)
+ return dkjson.encode(value)
+ end,
+ decode = function(text)
+ local value, _, err = dkjson.decode(text)
+ if err then
+ error(err, 0)
+ end
+ return value
+ end,
+ }
+end
+
+local function copy(source, fallback)
+ local out = {}
+ for key, value in pairs(source or fallback or {}) do
+ out[key] = value
+ end
+ return out
+end
+
+-- ---------------------------------------------------------------------------
+-- Conversations
+-- ---------------------------------------------------------------------------
+
+local conv_mt = {}
+conv_mt.__index = conv_mt
+conv_mt.__name = "fake.conversation"
+
+-- scripted = array of { role =, text = | blocks =, metadata = }. `record`, when
+-- given, is the child record seeded system messages are mirrored onto.
+local function new_conversation(scripted, record)
+ local messages = {}
+ for index, message in ipairs(scripted or {}) do
+ messages[index] = {
+ role = message.role or "user",
+ blocks = message.blocks or { { type = "text", text = message.text or "" } },
+ metadata = message.metadata,
+ }
+ end
+ return setmetatable({ _messages = messages, _record = record }, conv_mt)
+end
+
+-- Faithful to the binding: metadata is not part of a message table, so the only
+-- way to reach it is message_metadata(i).
+function conv_mt:messages()
+ local out = {}
+ for index, message in ipairs(self._messages) do
+ local blocks = {}
+ for block_index, block in ipairs(message.blocks) do
+ blocks[block_index] = copy(block)
+ end
+ out[index] = { role = message.role, blocks = blocks }
+ end
+ return out
+end
+
+function conv_mt:message_metadata(index)
+ local message = self._messages[tonumber(index) or 0]
+ if message == nil then
+ return nil
+ end
+ return message.metadata
+end
+
+function conv_mt:add_system_message(text, opts)
+ if type(text) ~= "string" then
+ error("panto: add_system_message expects a string", 2)
+ end
+ local metadata = opts and opts.metadata
+ if metadata ~= nil and type(metadata) ~= "table" then
+ error("panto: metadata must be a table", 2)
+ end
+ self._messages[#self._messages + 1] = {
+ role = "system",
+ blocks = { { type = "text", text = text } },
+ metadata = metadata,
+ }
+ local record = self._record
+ if record then
+ record.system_messages[#record.system_messages + 1] = { text = text, metadata = metadata }
+ local manifest = metadata and metadata.subagents
+ if type(manifest) == "table" and type(manifest.agent) == "string" then
+ record.label = manifest.agent
+ end
+ end
+end
+
+-- ---------------------------------------------------------------------------
+-- Session stores
+-- ---------------------------------------------------------------------------
+
+local store_mt = {}
+store_mt.__index = store_mt
+store_mt.__name = "fake.store"
+
+function store_mt:resolve(id)
+ local session = self._harness.sessions[id]
+ if session == nil then
+ return nil
+ end
+ return { id = id, message_count = #session, api_style = "messages" }
+end
+
+function store_mt:load(id)
+ local session = self._harness.sessions[id]
+ if session == nil then
+ return nil
+ end
+ return new_conversation(session)
+end
+
+-- ---------------------------------------------------------------------------
+-- Jobs
+-- ---------------------------------------------------------------------------
+
+local job_mt = {}
+job_mt.__index = job_mt
+job_mt.__name = "fake.job"
+
+local function new_job(cfg)
+ return setmetatable({
+ _settle_after = math.max(1, math.floor(tonumber(cfg.settle) or 1)),
+ _events = cfg.events or {},
+ _finish = cfg.finish,
+ _harness = cfg.harness,
+ _polls = 0,
+ _settled = false,
+ _result = nil,
+ _cancel_requested = false,
+ _closed = false,
+ }, job_mt)
+end
+
+function job_mt:next_event()
+ if self._closed or #self._events == 0 then
+ return nil
+ end
+ return table.remove(self._events, 1)
+end
+
+function job_mt:result()
+ if self._closed then
+ -- close() frees the settled result in the binding, so a caller that
+ -- reads it back afterwards sees exactly this.
+ return nil
+ end
+ self._polls = self._polls + 1
+ if self._polls > POLL_LIMIT then
+ error(string.format(
+ "fake job: polled %d times without settling; the drain loop is making no progress on a job with no wake pipe",
+ POLL_LIMIT), 0)
+ end
+ if self._harness then
+ self._harness.polls = self._harness.polls + 1
+ end
+ if self._settled then
+ return self._result
+ end
+ if self._cancel_requested then
+ self._result = { status = "cancelled", error = "cancelled" }
+ elseif self._polls >= self._settle_after then
+ self._result = self._finish(self)
+ else
+ return nil
+ end
+ self._settled = true
+ if self._harness then
+ self._harness.live = self._harness.live - 1
+ end
+ return self._result
+end
+
+function job_mt:request_cancel()
+ self._cancel_requested = true
+end
+
+function job_mt:close()
+ if self._closed then
+ return
+ end
+ self._closed = true
+ self._result = nil
+ self._events = {}
+end
+
+-- job(spec) -> a standalone fake job, for specs that drive the job machinery
+-- directly rather than through a child. spec = { result =, events =, settle = }.
+function M.job(spec)
+ spec = spec or {}
+ return new_job({
+ settle = spec.settle,
+ events = spec.events,
+ finish = function()
+ return spec.result or { status = "completed", text = "done" }
+ end,
+ })
+end
+
+-- ---------------------------------------------------------------------------
+-- Outcomes: which scripted turn a child gets, and what it settles to
+-- ---------------------------------------------------------------------------
+
+local function next_outcome(h, label)
+ local bucket = label and h.labelled[label]
+ if bucket and #bucket > 0 then
+ return table.remove(bucket, 1)
+ end
+ if #h.queued > 0 then
+ return table.remove(h.queued, 1)
+ end
+ return {}
+end
+
+-- Bound as late as possible: the profile label only exists once the child has
+-- been seeded with its manifest system message.
+local function outcome_for(h, record)
+ if record.outcome == nil then
+ record.outcome = next_outcome(h, record.label)
+ end
+ return record.outcome
+end
+
+local function child_id(h, record)
+ if record.session_id then
+ return record.session_id
+ end
+ if record.id == nil then
+ record.id = outcome_for(h, record).id or ("child-" .. record.index)
+ end
+ return record.id
+end
+
+-- The settled turn, in the shape agent:run_async reports it.
+local function settled_result(h, record)
+ local outcome = outcome_for(h, record)
+ local status = outcome.status or "completed"
+ local result = { status = status }
+ local one_shot = record.run and record.run.dispatch_tools == false
+ if status == "completed" then
+ if one_shot then
+ result.text = outcome.output
+ if outcome.structured_json then
+ local decl = record.tool_decls and record.tool_decls[1]
+ result.tool_calls = { {
+ id = "call-" .. record.index,
+ name = outcome.tool_name or (decl and decl.name) or "emit_result",
+ input = outcome.structured_json,
+ } }
+ end
+ else
+ result.text = outcome.output or ("output of " .. tostring(record.label or child_id(h, record)))
+ end
+ else
+ result.error = outcome.error or ("the child " .. status)
+ end
+
+ -- The durable file: a child that never reached its first assistant message
+ -- has none, which is what makes it unresumable.
+ local id = child_id(h, record)
+ if outcome.resumable == false then
+ h.sessions[id] = nil
+ else
+ h.sessions[id] = h.sessions[id] or {}
+ end
+ return result
+end
+
+-- ---------------------------------------------------------------------------
+-- Agents
+-- ---------------------------------------------------------------------------
+
+local agent_mt = {}
+agent_mt.__index = agent_mt
+agent_mt.__name = "fake.agent"
+
+function agent_mt:conversation()
+ return self._conv
+end
+
+function agent_mt:session_id()
+ return child_id(self._harness, self._record)
+end
+
+function agent_mt:tools()
+ local out = {}
+ for index, decl in ipairs(self._record.tool_decls or {}) do
+ out[index] = decl
+ end
+ return out
+end
+
+function agent_mt:set_tools(decls)
+ if self._borrowed then
+ error("panto: set_tools is not supported on a borrowed agent", 2)
+ end
+ if type(decls) ~= "table" then
+ error("panto: set_tools expects an array of declarations", 2)
+ end
+ local names = {}
+ for index, decl in ipairs(decls) do
+ if type(decl) ~= "table" or type(decl.name) ~= "string" then
+ error("panto: tool declaration " .. index .. " has no name", 2)
+ end
+ names[index] = decl.name
+ end
+ self._record.tool_decls = decls
+ self._record.tools = names
+end
+
+function agent_mt:run_async(options)
+ if self._borrowed then
+ return nil, "run_async: not supported on a borrowed agent"
+ end
+ if type(options) ~= "table" then
+ error("panto: run_async expects a table", 2)
+ end
+ if options.prompt == nil and options.blocks == nil then
+ return nil, "run_async: pass a prompt or blocks"
+ end
+ if options.metadata ~= nil and type(options.metadata) ~= "table" then
+ return nil, "run_async: metadata must be a table"
+ end
+
+ local h, record = self._harness, self._record
+ record.run = {
+ prompt = options.prompt,
+ blocks = options.blocks,
+ metadata = options.metadata,
+ dispatch_tools = options.dispatch_tools,
+ wake_fd = options.wake_fd,
+ }
+ record.prompt = options.prompt
+ h.runs[#h.runs + 1] = record.run
+
+ local job = new_job({
+ settle = outcome_for(h, record).settle,
+ events = outcome_for(h, record).events,
+ harness = h,
+ finish = function()
+ return settled_result(h, record)
+ end,
+ })
+ h.jobs[#h.jobs + 1] = job
+ record.job = job
+ h.live = h.live + 1
+ if h.live > h.max_live then
+ h.max_live = h.live
+ end
+ return job
+end
+
+-- ---------------------------------------------------------------------------
+-- install
+-- ---------------------------------------------------------------------------
+
+-- install(opts) -> handle
+--
+-- opts = {
+-- session = { session_id =, session_dir =, model =, reasoning = },
+-- models_response = table | function(query),
+-- primary_tools = array of decl tables (defaults to DEFAULT_TOOL_NAMES),
+-- primary_messages = array of { role =, text = } for the primary conversation,
+-- sessions = { [id] = array of scripted messages } already on disk,
+-- unknown_models = { ["provider:model"] = true } resolve_model rejects,
+-- }
+function M.install(opts)
+ opts = opts or {}
+
+ for index = #made_dirs, 1, -1 do
+ made_dirs[index] = nil
+ end
+
+ -- A partial `session` overrides only the fields it names.
+ local session = copy(DEFAULT_SESSION)
+ for key, value in pairs(opts.session or {}) do
+ session[key] = value
+ end
+
+ local handle = {
+ session = session,
+ models_response = opts.models_response or DEFAULT_OVERVIEW,
+ primary_tools = opts.primary_tools or default_tools(),
+ sessions = opts.sessions or {},
+ unknown_models = opts.unknown_models or {},
+ spawns = {},
+ runs = {},
+ jobs = {},
+ resolves = {},
+ stores = {},
+ tools = {},
+ tools_by_name = {},
+ commands = {},
+ commands_by_name = {},
+ models_queries = {},
+ subscriptions = {},
+ on_by_name = {},
+ mkdirs = made_dirs,
+ queued = {},
+ labelled = {},
+ polls = 0,
+ live = 0,
+ max_live = 0,
+ }
+
+ function handle.queue(outcome)
+ handle.queued[#handle.queued + 1] = outcome or {}
+ end
+
+ function handle.queue_for(label, outcome)
+ local bucket = handle.labelled[label]
+ if bucket == nil then
+ bucket = {}
+ handle.labelled[label] = bucket
+ end
+ bucket[#bucket + 1] = outcome or {}
+ end
+
+ -- A child session that already exists on disk, for the resume cases.
+ function handle.add_session(id, messages)
+ handle.sessions[id] = messages or {}
+ end
+
+ function handle.made_dir(path)
+ for _, made in ipairs(handle.mkdirs) do
+ if made == path then
+ return true
+ end
+ end
+ return false
+ end
+
+ -- resolve_model hands back an opaque config; this is how a spec gets from
+ -- the config a child was built with back to the query that produced it.
+ local resolved_from = setmetatable({}, { __mode = "k" })
+
+ local ext = {}
+
+ function ext.session_info()
+ return copy(handle.session)
+ end
+
+ function ext.resolve_model(query)
+ if type(query) ~= "table" then
+ return nil, "resolve_model: expected a table of arguments"
+ end
+ local request = {
+ model = query.model,
+ reasoning = query.reasoning,
+ tool_choice = query.tool_choice,
+ }
+ handle.resolves[#handle.resolves + 1] = request
+ if type(query.model) ~= "string" or query.model == "" then
+ return nil, "resolve_model: model must be a 'provider:model' string"
+ end
+ if handle.unknown_models[query.model] then
+ return nil, string.format("resolve_model: unknown model '%s'", query.model)
+ end
+ local cfg = {
+ model = query.model,
+ reasoning = query.reasoning,
+ style = "messages",
+ wire_model = query.model:match(":(.+)$") or query.model,
+ }
+ resolved_from[cfg] = request
+ return cfg
+ end
+
+ function ext.models(query)
+ handle.models_queries[#handle.models_queries + 1] = query or {}
+ local response = handle.models_response
+ if type(response) == "function" then
+ return response(query or {})
+ end
+ return response
+ end
+
+ function ext.on(name, fn)
+ handle.subscriptions[#handle.subscriptions + 1] = { name = name, fn = fn }
+ handle.on_by_name[name] = fn
+ end
+
+ -- Fire a subscribed lifecycle handler, the way the host would.
+ function handle.emit(name, event)
+ for _, subscription in ipairs(handle.subscriptions) do
+ if subscription.name == name then
+ subscription.fn(event)
+ end
+ end
+ end
+
+ function ext.register_tool(tool)
+ handle.tools[#handle.tools + 1] = tool
+ handle.tools_by_name[tool.name] = tool
+ end
+
+ function ext.register_command(command)
+ handle.commands[#handle.commands + 1] = command
+ handle.commands_by_name[command.name] = command
+ end
+
+ ext.json = json_codec()
+
+ -- The primary agent, borrowed: readable tools and conversation, nothing else.
+ ext.agent = setmetatable({
+ _borrowed = true,
+ _harness = handle,
+ _record = { index = 0, tool_decls = handle.primary_tools, system_messages = {}, tools = {} },
+ _conv = new_conversation(opts.primary_messages),
+ }, agent_mt)
+
+ local function new_store(kind, arg)
+ local dir = nil
+ if type(arg) == "table" then
+ dir = arg.dir
+ elseif type(arg) == "string" then
+ dir = arg
+ end
+ if kind == "fs" and type(dir) ~= "string" then
+ error("panto.file_system_jsonl_store: missing dir", 2)
+ end
+ handle.stores[#handle.stores + 1] = dir
+ return setmetatable({ dir = dir, kind = kind, _harness = handle }, store_mt)
+ end
+
+ local function new_agent(options)
+ if type(options) ~= "table" then
+ error("panto.agent expects a table", 2)
+ end
+ local record = {
+ index = #handle.spawns + 1,
+ config = options.config,
+ store_dir = type(options.store) == "table" and options.store.dir or nil,
+ session_id = options.session_id,
+ resumed = options.session_id ~= nil,
+ system_messages = {},
+ tools = {},
+ resolve = resolved_from[options.config],
+ }
+ if record.resolve then
+ record.model = record.resolve.model
+ record.reasoning = record.resolve.reasoning
+ record.tool_choice = record.resolve.tool_choice
+ end
+ handle.spawns[record.index] = record
+
+ local conv = options.conversation
+ if conv == nil then
+ conv = new_conversation(nil, record)
+ else
+ conv._record = record
+ end
+ return setmetatable({ _harness = handle, _record = record, _conv = conv }, agent_mt)
+ end
+
+ handle.ext = ext
+ package.loaded.panto = {
+ ext = ext,
+ agent = new_agent,
+ file_system_jsonl_store = function(arg)
+ return new_store("fs", arg)
+ end,
+ null_store = function()
+ return new_store("null", nil)
+ end,
+ }
+
+ function handle.restore()
+ package.loaded.panto = nil
+ end
+
+ return handle
+end
+
+return M
diff --git a/spec/run.lua b/spec/run.lua
new file mode 100644
index 0000000..ee9f432
--- /dev/null
+++ b/spec/run.lua
@@ -0,0 +1,81 @@
+-- The spec runner: `lua spec/run.lua` from anywhere in the repo.
+--
+-- Expected environment. Plain Lua 5.4 with the repo root on package.path, which
+-- this file arranges from `arg[0]`, plus the rocks the extension depends on
+-- (lyaml, toml2lua, luv) and dkjson for the specs' JSON decoding. `mise run
+-- check` puts the ./.rocks tree on LUA_PATH/LUA_CPATH first and is the intended
+-- entry point; a bare `lua spec/run.lua` also works if those rocks are on the
+-- default path. `panto lua spec/run.lua` works too — panto's own rocks tree
+-- already carries luv.
+--
+-- Missing optional rocks do not fail the run. A test that needs one requires it
+-- lazily and returns `"skip", reason`; the runner prints a SKIP line, counts it,
+-- and still exits 0. Only a real assertion failure or an unexpected error exits
+-- 1. `mise run deps` installs everything, so a local run exercises all of it.
+--
+-- Test files. Every spec/test_*.lua returns an ordered array of { name, fn }.
+-- `fn` asserts and returns nothing to pass, or returns "skip", reason. Ordering
+-- is the array's, so a file reads top to bottom.
+
+local script = (arg and arg[0]) or "spec/run.lua"
+local spec_dir = script:match("^(.*)/[^/]+$") or "."
+local root = spec_dir:match("^(.*)/[^/]+$") or "."
+
+package.path = root .. "/?.lua;" .. root .. "/?/init.lua;" .. package.path
+
+local function test_files()
+ local found = {}
+ local pipe = io.popen("ls '" .. spec_dir .. "'/test_*.lua 2>/dev/null")
+ if not pipe then
+ return found
+ end
+ for line in pipe:lines() do
+ found[#found + 1] = line
+ end
+ pipe:close()
+ table.sort(found)
+ return found
+end
+
+local function traceback(err)
+ return debug.traceback(tostring(err), 2)
+end
+
+local passed, skipped, failed = 0, 0, 0
+
+local function record(label, ok, first, second)
+ if not ok then
+ failed = failed + 1
+ print("FAIL " .. label)
+ print(first)
+ elseif first == "skip" then
+ skipped = skipped + 1
+ print("SKIP " .. label .. " — " .. tostring(second))
+ else
+ passed = passed + 1
+ print("ok " .. label)
+ end
+end
+
+for _, file in ipairs(test_files()) do
+ local name = file:match("[^/]+$")
+ local chunk, load_err = loadfile(file)
+ if not chunk then
+ record(name, false, "could not load: " .. tostring(load_err))
+ else
+ local loaded, cases = xpcall(chunk, traceback)
+ if not loaded then
+ record(name, false, cases)
+ elseif type(cases) ~= "table" then
+ record(name, false, "expected an array of { name, fn }, got " .. type(cases))
+ else
+ for _, case in ipairs(cases) do
+ local ok, first, second = xpcall(case[2], traceback)
+ record(name .. ": " .. tostring(case[1]), ok, first, second)
+ end
+ end
+ end
+end
+
+print(string.format("\n%d passed, %d skipped, %d failed", passed, skipped, failed))
+os.exit(failed == 0 and 0 or 1)
diff --git a/spec/test_frontmatter.lua b/spec/test_frontmatter.lua
new file mode 100644
index 0000000..3f0c4c5
--- /dev/null
+++ b/spec/test_frontmatter.lua
@@ -0,0 +1,88 @@
+-- subagents/frontmatter.lua: splitting a profile into YAML header and body.
+--
+-- The body is the part a broken header must never cost the user, so every
+-- degraded case is checked for "body preserved verbatim" as well as for the
+-- warning. Cases that actually parse YAML need lyaml and skip without it.
+
+local frontmatter = require("subagents.frontmatter")
+
+local function lyaml_or_skip()
+ return pcall(require, "lyaml")
+end
+
+return {
+ { "fenced header parses and keeps the body verbatim", function()
+ if not lyaml_or_skip() then
+ return "skip", "lyaml is not installed"
+ end
+ local body = "You are a reviewer.\n\n indented line \n"
+ local data, parsed_body, warning = frontmatter.parse(
+ "---\nname: reviewer\ndescription: Reviews changes\nmodel: anthropic:sonnet\n---\n" .. body)
+ assert(warning == nil, "unexpected warning: " .. tostring(warning))
+ assert(type(data) == "table", "expected a mapping")
+ assert(data.name == "reviewer", tostring(data.name))
+ assert(data.description == "Reviews changes")
+ assert(data.model == "anthropic:sonnet", tostring(data.model))
+ assert(parsed_body == body, string.format("body was rewritten: %q", parsed_body))
+ end },
+
+ { "CRLF fences are tolerated", function()
+ if not lyaml_or_skip() then
+ return "skip", "lyaml is not installed"
+ end
+ local data, body, warning = frontmatter.parse("---\r\nname: crlf\r\n---\r\nbody\r\n")
+ assert(warning == nil, tostring(warning))
+ assert(type(data) == "table" and data.name == "crlf", "header did not parse")
+ assert(body == "body\r\n", string.format("%q", body))
+ end },
+
+ { "no fence means the whole file is the body", function()
+ local text = "You are a reviewer.\n\n---\n\nNot a header.\n"
+ local data, body, warning = frontmatter.parse(text)
+ assert(data == nil, "expected no metadata")
+ assert(body == text, "body was rewritten")
+ assert(warning == nil, tostring(warning))
+ end },
+
+ { "an unterminated fence is treated as prose", function()
+ local text = "---\nname: never closed\n\nstill prose\n"
+ local data, body, warning = frontmatter.parse(text)
+ assert(data == nil, "expected no metadata")
+ assert(body == text, "body was rewritten")
+ assert(warning == nil, "a lone rule is not an error")
+ end },
+
+ { "an empty fenced block is an empty mapping", function()
+ local data, body, warning = frontmatter.parse("---\n---\nbody\n")
+ assert(type(data) == "table" and next(data) == nil, "expected an empty mapping")
+ assert(body == "body\n", string.format("%q", body))
+ assert(warning == nil, tostring(warning))
+ end },
+
+ { "broken YAML warns and keeps the body", function()
+ if not lyaml_or_skip() then
+ return "skip", "lyaml is not installed"
+ end
+ local data, body, warning = frontmatter.parse("---\na: [unclosed\n---\nbody\n")
+ assert(data == nil, "a broken header must not produce metadata")
+ assert(body == "body\n", string.format("%q", body))
+ assert(type(warning) == "string" and warning:find("did not parse", 1, true),
+ "expected a parse warning, got " .. tostring(warning))
+ end },
+
+ { "a non-mapping document warns and keeps the body", function()
+ if not lyaml_or_skip() then
+ return "skip", "lyaml is not installed"
+ end
+ local data, body, warning = frontmatter.parse("---\njust a string\n---\nbody\n")
+ assert(data == nil, "a scalar header must not produce metadata")
+ assert(body == "body\n", string.format("%q", body))
+ assert(type(warning) == "string" and warning:find("not a mapping", 1, true),
+ "expected a mapping warning, got " .. tostring(warning))
+ end },
+
+ { "empty input is empty output", function()
+ local data, body, warning = frontmatter.parse("")
+ assert(data == nil and body == "" and warning == nil)
+ end },
+}
diff --git a/spec/test_init.lua b/spec/test_init.lua
new file mode 100644
index 0000000..800dcaa
--- /dev/null
+++ b/spec/test_init.lua
@@ -0,0 +1,180 @@
+-- init.lua: the extension entry point pantograph evaluates and activates.
+--
+-- Activation is the whole contract with the host: the wrong shape, a missing
+-- tool, a description that does not name the discovered profiles, or a missing
+-- lifecycle subscription is invisible until a user notices the tools are gone or
+-- a cancelled turn leaves children running. Discovery is pointed at a temporary
+-- config layer, so the assertions do not depend on this machine's ~/.config; the
+-- first case skips when luv or lyaml is missing because the profile it looks for
+-- could not be read without them.
+
+local fake = require("spec.fake_ext")
+local jobs = require("subagents.jobs")
+local paths = require("subagents.paths")
+
+local entry = require("init")
+
+local function has(text, needle)
+ assert(type(text) == "string", "expected a string, got " .. type(text))
+ assert(text:find(needle, 1, true), "expected to find " .. needle .. " in:\n" .. tostring(text))
+end
+
+-- Activate against the fake host with discovery pointed at nothing, for the
+-- cases that care about registration rather than profiles. Profile discovery is
+-- cached in subagents.spawn, so a case that ran earlier may have filled it.
+local function activate_bare(fn, opts)
+ local original = paths.config_roots
+ paths.config_roots = function()
+ return {}
+ end
+ local handle = fake.install(opts)
+ local ok, err = pcall(function()
+ if opts and opts.before then
+ opts.before(handle)
+ end
+ entry.activate()
+ end)
+ paths.config_roots = original
+ local result = table.pack(pcall(fn, handle, ok, err))
+ handle.restore()
+ if not result[1] then
+ error(result[2], 0)
+ end
+end
+
+return {
+ { "the entry is the extension shape pantograph expects", function()
+ assert(entry.name == "subagents", tostring(entry.name))
+ assert(type(entry.activate) == "function", "activate must be a function")
+ end },
+
+ { "activation registers the four tools and names the discovered profiles", function()
+ local ok_uv, uv = pcall(require, "luv")
+ if not ok_uv then
+ return "skip", "luv is not installed"
+ end
+ if not pcall(require, "lyaml") then
+ return "skip", "lyaml is not installed"
+ end
+
+ local tmp = assert(uv.fs_mkdtemp("/tmp/panto-subagents-init-XXXXXX"))
+ assert(os.execute("mkdir -p " .. tmp .. "/agents"))
+ local file = assert(io.open(tmp .. "/agents/reviewer.md", "w"))
+ file:write("---\ndescription: Reviews changes\n---\nYou are a reviewer.\n")
+ file:close()
+
+ local original_roots = paths.config_roots
+ paths.config_roots = function(kind)
+ return { tmp .. "/" .. kind }
+ end
+ local handle = fake.install()
+
+ local ok, err = pcall(entry.activate)
+
+ paths.config_roots = original_roots
+ handle.restore()
+ os.execute("rm -rf " .. tmp)
+ assert(ok, tostring(err))
+
+ for _, name in ipairs({ "subagents.run", "subagents.models", "subagents.lua", "subagents.workflow" }) do
+ assert(handle.tools_by_name[name], "missing tool " .. name)
+ end
+ assert(#handle.tools == 4, "expected exactly four tools, saw " .. #handle.tools)
+
+ local run_tool = handle.tools_by_name["subagents.run"]
+ has(run_tool.description, "reviewer — Reviews changes")
+ has(run_tool.description, "exactly one of `agent`")
+ has(run_tool.description, "subagents.models")
+ 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(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.workflow"].schema.properties.steps.items.required,
+ "the workflow tool describes its step shape")
+ end },
+
+ { "an interrupted turn cancels every live child, and its end closes them", function()
+ activate_bare(function(handle, ok, err)
+ assert(ok, tostring(err))
+ assert(type(handle.on_by_name["turn_interrupt"]) == "function",
+ "an interrupted turn must be able to cancel its children")
+ assert(type(handle.on_by_name["turn_end"]) == "function",
+ "a finished turn must be able to close its children")
+
+ -- 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
+ -- leaked handle would count against the next case's gate.
+ local job = fake.job({ settle = 99 })
+ local started = assert(jobs.start({ label = "alpha", build = function()
+ return job
+ end }))
+ local checked, failure = pcall(function()
+ assert(started:result() == nil, "the child is still running")
+ handle.emit("turn_interrupt", { phase = "interrupt" })
+ assert(job._cancel_requested, "an interrupted turn asks its children to stop")
+ handle.emit("turn_end", { phase = "end", reason = "interrupted" })
+ assert(job._closed, "the end of the turn joins every child")
+ end)
+ jobs.close_all()
+ assert(checked, failure)
+ end)
+ end },
+
+ { "only the subagents tool calls claim a progress component", function()
+ activate_bare(function(handle, ok, err)
+ assert(ok, tostring(err))
+ assert(type(handle.on_by_name["tool_call_complete"]) == "function",
+ "children report progress through the tool call that started them")
+
+ local claimed
+ handle.emit("tool_call_complete", {
+ id = "call-1",
+ tool_name = "subagents.run",
+ set_component = function(_, component)
+ claimed = component
+ return { invalidate = function() end, alive = function()
+ return true
+ end }
+ end,
+ })
+ assert(type(claimed) == "table" and type(claimed.render) == "function",
+ "the entry is given a component that renders the cards")
+
+ local foreign
+ handle.emit("tool_call_complete", {
+ id = "call-2",
+ tool_name = "read_file",
+ set_component = function(_, component)
+ foreign = component
+ end,
+ })
+ assert(foreign == nil, "another tool's entry is left alone")
+ end)
+ end },
+
+ { "a host without resolve_model fails activation loudly", function()
+ activate_bare(function(handle, ok, err)
+ assert(not ok, "activation must not silently register unusable tools")
+ has(tostring(err), "too old")
+ assert(#handle.tools == 0, "nothing is registered by a failed activation")
+ end, {
+ before = function(handle)
+ handle.ext.resolve_model = nil
+ end,
+ })
+ end },
+
+ { "a host without panto.agent fails activation loudly", function()
+ activate_bare(function(handle, ok, err)
+ assert(not ok, "a host that cannot build a child agent is too old")
+ has(tostring(err), "too old")
+ assert(#handle.tools == 0, "nothing is registered by a failed activation")
+ end, {
+ before = function()
+ package.loaded.panto.agent = nil
+ end,
+ })
+ end },
+}
diff --git a/spec/test_jobs.lua b/spec/test_jobs.lua
new file mode 100644
index 0000000..e6ce097
--- /dev/null
+++ b/spec/test_jobs.lua
@@ -0,0 +1,244 @@
+-- subagents/jobs.lua: the concurrency gate, the queue, cancellation, and the
+-- await contract every caller (run.lua, workflow.lua) is written against.
+--
+-- These cases drive the job machinery directly with hand-made fake jobs rather
+-- than through a child, so a failure here points at the gate and not at spawn
+-- policy. The fake jobs have no wake pipe (the harness has none), so awaiting
+-- drains them in place; `settle = N` means "settles on the Nth poll", which is
+-- how the ordering cases stay deterministic without an event loop.
+
+local fake = require("spec.fake_ext")
+local jobs = require("subagents.jobs")
+
+-- The host may report one result or an array of them; both are normalized here
+-- exactly as workflow.lua normalizes them.
+local function as_array(value)
+ if type(value) ~= "table" then
+ return {}
+ end
+ if value.status ~= nil then
+ return { value }
+ end
+ return value
+end
+
+-- Every case leaves the module clean: close_all drops whatever is still live.
+local function with_jobs(fn, max_concurrent)
+ local original = jobs.MAX_CONCURRENT
+ if max_concurrent then
+ jobs.MAX_CONCURRENT = max_concurrent
+ end
+ local ok, err = pcall(fn)
+ pcall(jobs.close_all)
+ jobs.MAX_CONCURRENT = original
+ if not ok then
+ error(err, 0)
+ end
+end
+
+-- A starter that records which builds actually ran, and the jobs they made.
+local function starter()
+ local built, made = {}, {}
+ local function start(name, spec)
+ spec = spec or {}
+ return jobs.start({
+ label = name,
+ id = spec.id,
+ one_shot = spec.one_shot,
+ on_event = spec.on_event,
+ build = function()
+ built[#built + 1] = name
+ if spec.build_error then
+ return nil, spec.build_error
+ end
+ local job = fake.job({
+ settle = spec.settle,
+ events = spec.events,
+ result = spec.result or { status = "completed", text = name },
+ })
+ made[name] = job
+ return job
+ end,
+ })
+ end
+ return start, built, made
+end
+
+local function contains(list, value)
+ for _, entry in ipairs(list) do
+ if entry == value then
+ return true
+ end
+ end
+ return false
+end
+
+return {
+ { "a started job settles through await", function()
+ with_jobs(function()
+ local start = starter()
+ local handle = assert(start("alpha"))
+ assert(handle:result() == nil, "a job that has not settled has no result")
+
+ local results = jobs.await({ handle }, "all")
+ assert(#results == 1, "one handle, one result")
+ assert(results[1].status == "completed", tostring(results[1].status))
+ assert(results[1].text == "alpha", tostring(results[1].text))
+ assert(handle:result().text == "alpha", "the settled result stays on the handle")
+ end)
+ end },
+
+ { "await all returns results in input order, not settle order", function()
+ with_jobs(function()
+ local start = starter()
+ local handles = {
+ assert(start("alpha", { settle = 3 })),
+ assert(start("beta", { settle = 1 })),
+ assert(start("gamma", { settle = 2 })),
+ }
+ local results = jobs.await(handles, "all")
+ assert(#results == 3, "expected three results")
+ assert(results[1].text == "alpha", tostring(results[1].text))
+ assert(results[2].text == "beta", tostring(results[2].text))
+ assert(results[3].text == "gamma", tostring(results[3].text))
+ end)
+ end },
+
+ { "await first returns the earliest settler and the remaining handles", function()
+ with_jobs(function()
+ local start = starter()
+ local handles = {
+ assert(start("alpha", { settle = 3 })),
+ assert(start("beta", { settle = 1 })),
+ assert(start("gamma", { settle = 2 })),
+ }
+ local seen = {}
+ while #handles > 0 do
+ local results, remaining = jobs.await(handles, "first")
+ results = as_array(results)
+ assert(#results >= 1, "an await that returns must settle something")
+ for _, result in ipairs(results) do
+ seen[#seen + 1] = result.text
+ end
+ assert(type(remaining) == "table", "first mode reports what is still running")
+ assert(#remaining < #handles, "every await makes progress")
+ handles = remaining
+ end
+ assert(seen[1] == "beta", "the lowest settle key comes back first: " .. table.concat(seen, ","))
+ assert(contains(seen, "gamma") and contains(seen, "alpha"), table.concat(seen, ","))
+ assert(#seen == 3, table.concat(seen, ","))
+ end)
+ end },
+
+ { "the gate runs four 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
+ 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")
+
+ 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")
+ end)
+ end },
+
+ { "a child cancelled while queued never starts", function()
+ with_jobs(function()
+ local start, built = starter()
+ local handles = {}
+ for _, name in ipairs({ "a", "b", "c", "d", "e" }) do
+ handles[#handles + 1] = assert(start(name))
+ end
+ handles[5]:cancel()
+
+ local result = handles[5]: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")
+
+ 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")
+ end)
+ end },
+
+ { "cancelling a running child asks its job to cancel", function()
+ with_jobs(function()
+ local start, _, made = starter()
+ local handle = assert(start("alpha", { settle = 5 }))
+ handle:cancel()
+ assert(made.alpha._cancel_requested, "the job was asked to cancel")
+
+ local results = jobs.await({ handle }, "all")
+ assert(results[1].status == "cancelled", tostring(results[1].status))
+ end)
+ end },
+
+ { "events reach on_event before the job settles", function()
+ with_jobs(function()
+ local seen = {}
+ local start = starter()
+ local handle = assert(start("alpha", {
+ settle = 2,
+ events = {
+ { type = "content_delta", text = "half " },
+ { type = "content_delta", text = "a thought" },
+ },
+ on_event = function(event)
+ seen[#seen + 1] = event.text
+ end,
+ }))
+ jobs.await({ handle }, "all")
+ assert(table.concat(seen) == "half a thought", "events arrive in order: " .. table.concat(seen, "|"))
+ end)
+ end },
+
+ { "cancel_all stops the running children and drops the queued ones", function()
+ with_jobs(function()
+ local start, built, made = starter()
+ local handles = {}
+ for _, name in ipairs({ "a", "b", "c", "d", "e" }) do
+ handles[#handles + 1] = assert(start(name, { settle = 5 }))
+ end
+ jobs.cancel_all()
+
+ for _, name in ipairs({ "a", "b", "c", "d" }) 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")
+
+ local results = jobs.await(handles, "all")
+ for index, result in ipairs(results) do
+ assert(result.status == "cancelled", "child " .. index .. " is " .. tostring(result.status))
+ end
+ end)
+ end },
+
+ { "close_all closes every job it started", function()
+ with_jobs(function()
+ local start, _, made = starter()
+ local handle = assert(start("alpha"))
+ jobs.await({ handle }, "all")
+ assert(not made.alpha._closed, "awaiting does not close a job")
+
+ jobs.close_all()
+ assert(made.alpha._closed, "turn end closes the job")
+ end)
+ end },
+
+ { "a build failure is a nil return, never an exception", function()
+ with_jobs(function()
+ local start = starter()
+ local handle, err = start("alpha", { build_error = "the host refused" })
+ assert(handle == nil, "a failed build starts nothing")
+ assert(tostring(err):find("the host refused", 1, true), tostring(err))
+ end)
+ end },
+}
diff --git a/spec/test_luatool.lua b/spec/test_luatool.lua
new file mode 100644
index 0000000..83b4984
--- /dev/null
+++ b/spec/test_luatool.lua
@@ -0,0 +1,188 @@
+-- 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.
+
+local fake = require("spec.fake_ext")
+local luatool = require("subagents.luatool")
+
+local function has(text, needle)
+ assert(type(text) == "string", "expected a string, got " .. type(text))
+ assert(text:find(needle, 1, true), "expected to find " .. needle .. " in:\n" .. tostring(text))
+end
+
+local function profile_set()
+ local set = { list = {}, by_name = {}, warnings = {} }
+ for _, name in ipairs({ "alpha", "beta" }) do
+ local profile = { name = name, description = name, body = "You are " .. name .. ".\n" }
+ set.list[#set.list + 1] = profile
+ set.by_name[name] = profile
+ end
+ return set
+end
+
+local function with_host(fn)
+ local handle = fake.install()
+ local ok, err = pcall(fn, handle, profile_set())
+ handle.restore()
+ if not ok then
+ error(err, 0)
+ end
+end
+
+return {
+ { "the guest environment has no filesystem, process, or module access", function()
+ local env = luatool.build_env()
+ for _, name in ipairs({
+ "os", "io", "debug", "package", "require", "load", "loadstring", "dofile",
+ "loadfile", "coroutine", "setmetatable", "getmetatable", "rawset", "rawget",
+ "collectgarbage", "arg", "pcall", "xpcall",
+ }) 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")
+ end },
+
+ { "the string metatable stays reachable, and stays 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")
+ 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")
+ end)
+ end },
+
+ { "source that does not return a workflow is refused", 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 },
+
+ { "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")
+ 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")
+ end)
+ end },
+
+ { "the guest cannot catch the budget error and spin again", 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" }
+ end)
+ ]]
+ local text = luatool.handle({ prompt = "x", source = source }, profiles)
+ has(text, "Error:")
+ has(text, "pcall")
+ end)
+ end },
+
+ { "a fan-out runs end to end and renders one block per child", 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" })
+
+ 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")
+ end)
+ ]]
+ local text = luatool.handle({ prompt = "the task", source = source }, profiles)
+
+ 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")
+ end)
+ end },
+
+ { "the job budget applies to a generated workflow", 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
+ 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 },
+
+ { "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 })
+ 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 private state, not a ctx field")
+ end)
+ end },
+}
diff --git a/spec/test_models.lua b/spec/test_models.lua
new file mode 100644
index 0000000..ae01396
--- /dev/null
+++ b/spec/test_models.lua
@@ -0,0 +1,158 @@
+-- subagents/models.lua: the four catalog query forms and the profile join.
+--
+-- 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.
+
+local fake = require("spec.fake_ext")
+local models = require("subagents.models")
+
+local function has(text, needle)
+ assert(type(text) == "string", "expected a string result, got " .. type(text))
+ assert(text:find(needle, 1, true), "expected to find " .. needle .. " in:\n" .. tostring(text))
+end
+
+local function catalog(query)
+ if query.model then
+ if query.model == "anthropic:sonnet" then
+ return {
+ found = true,
+ ref = "anthropic:sonnet",
+ wire_model = "claude-sonnet-4-6",
+ reasoning_default = "medium",
+ reasoning_levels = { "low", "medium", "high" },
+ context_window = 200000,
+ max_tokens = 64000,
+ }
+ end
+ return { found = false }
+ end
+ if query.provider or query.query then
+ return {
+ matches = {
+ { ref = "anthropic:sonnet", wire_model = "claude-sonnet-4-6", reasoning_default = "medium" },
+ { ref = "anthropic:opus", wire_model = "claude-opus-4-6" },
+ },
+ truncated = true,
+ }
+ end
+ return {
+ model = "anthropic:sonnet",
+ reasoning = "medium",
+ providers = { { name = "anthropic", style = "messages", models = 7 } },
+ }
+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())
+ handle.restore()
+ if not ok then
+ error(err, 0)
+ end
+end
+
+return {
+ { "no arguments reports the inherited model and provider counts", function()
+ with_host(function(handle, profiles)
+ local text = models.handle({}, profiles)
+ has(text, "inherited model: anthropic:sonnet")
+ has(text, "inherited reasoning: medium")
+ has(text, "anthropic (messages, 7 models)")
+ assert(next(handle.models_queries[1]) == nil, "the overview query carries no fields")
+ end)
+ end },
+
+ { "an exact model lookup reports its reasoning levels", function()
+ with_host(function(handle, profiles)
+ local text = models.handle({ model = "anthropic:sonnet" }, profiles)
+ has(text, "model: anthropic:sonnet")
+ has(text, "wire model: claude-sonnet-4-6")
+ has(text, "default reasoning: medium")
+ has(text, "reasoning levels: low, medium, high")
+ has(text, "context window: 200000")
+ assert(handle.models_queries[1].model == "anthropic:sonnet")
+ end)
+ end },
+
+ { "an unknown model says so and suggests a search", function()
+ with_host(function(handle, profiles)
+ local text = models.handle({ model = "acme:turbo" }, profiles)
+ has(text, "No configured model matches 'acme:turbo'")
+ has(text, "subagents.models")
+ end)
+ end },
+
+ { "a search reports matches and says when more exist", function()
+ with_host(function(handle, profiles)
+ local text = models.handle({ query = "son" }, profiles)
+ has(text, "2 match(es):")
+ has(text, "anthropic:sonnet — wire claude-sonnet-4-6, default reasoning medium")
+ has(text, "more exist — refine the query")
+ assert(handle.models_queries[1].limit == 10, "the default limit is 10")
+ end)
+ end },
+
+ { "limit is clamped to 1..50", function()
+ with_host(function(handle, profiles)
+ models.handle({ query = "son", limit = 500 }, profiles)
+ assert(handle.models_queries[1].limit == 50, tostring(handle.models_queries[1].limit))
+ models.handle({ provider = "anthropic", limit = 0 }, profiles)
+ assert(handle.models_queries[2].limit == 1, tostring(handle.models_queries[2].limit))
+ models.handle({ query = "son", limit = 7.6 }, profiles)
+ assert(handle.models_queries[3].limit == 7, "a fractional limit is floored")
+ 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")
+ has(models.handle({ model = "" }, profiles), "Error: `model` must be a non-empty string")
+ assert(#handle.models_queries == 0, "a refused call never reaches the host")
+ end)
+ end },
+}
diff --git a/spec/test_profiles.lua b/spec/test_profiles.lua
new file mode 100644
index 0000000..3a052b9
--- /dev/null
+++ b/spec/test_profiles.lua
@@ -0,0 +1,138 @@
+-- subagents/profiles.lua: two-layer recursive discovery over real directories.
+--
+-- Discovery is exercised against temporary directories rather than the machine's
+-- real config, by passing explicit roots to `discover` — the same seam the
+-- extension uses for the user and project layers, in the same order.
+--
+-- Needs luv (the recursive walk) and lyaml (the frontmatter); without either the
+-- whole file skips rather than asserting on a degraded parse.
+
+local profiles = require("subagents.profiles")
+
+local function write(path, text)
+ assert(os.execute("mkdir -p " .. (path:match("^(.*)/[^/]+$") or ".")))
+ local file = assert(io.open(path, "w"))
+ file:write(text)
+ file:close()
+end
+
+-- Build both layers once; every case reads the same discovery result.
+local function discover_fixture()
+ local ok_uv, uv = pcall(require, "luv")
+ if not ok_uv then
+ return nil, "luv is not installed"
+ end
+ if not pcall(require, "lyaml") then
+ return nil, "lyaml is not installed"
+ end
+
+ local tmp = assert(uv.fs_mkdtemp("/tmp/panto-subagents-profiles-XXXXXX"))
+ local user = tmp .. "/user/agents"
+ local project = tmp .. "/project/agents"
+
+ write(user .. "/reviewer.md", table.concat({
+ "---",
+ "description: Reviews changes",
+ "model: anthropic:sonnet",
+ "reasoning: high",
+ "---",
+ "You are a reviewer.",
+ "",
+ }, "\n"))
+ write(user .. "/nested/deeper/planner.md", "You plan.\n")
+ write(user .. "/shared.md", "---\nname: shared\ndescription: from the user layer\n---\nuser body\n")
+ write(user .. "/renamed.md", "---\nname: from-frontmatter\n---\nbody\n")
+ write(project .. "/shared.md", "---\nname: shared\ndescription: from the project layer\n---\nproject body\n")
+ write(project .. "/foreign.md", "---\ndescription: written for another harness\nmodel: claude-sonnet-4\n---\nbody\n")
+
+ local found = profiles.discover({ user, project })
+ os.execute("rm -rf " .. tmp)
+ return found
+end
+
+local fixture, skip_reason = discover_fixture()
+
+local function fixture_or_skip()
+ if not fixture then
+ return nil, skip_reason
+ end
+ return fixture
+end
+
+return {
+ { "frontmatter fields land on the profile", function()
+ local found, reason = fixture_or_skip()
+ if not found then
+ return "skip", reason
+ end
+ local reviewer = found.by_name.reviewer
+ assert(reviewer, "reviewer was not discovered")
+ assert(reviewer.name == "reviewer", "name should default to the file stem")
+ assert(reviewer.description == "Reviews changes", tostring(reviewer.description))
+ assert(reviewer.model == "anthropic:sonnet", tostring(reviewer.model))
+ assert(reviewer.reasoning == "high", tostring(reviewer.reasoning))
+ assert(reviewer.body:find("You are a reviewer.", 1, true), "body was lost")
+ end },
+
+ { "discovery recurses and defaults the name to the stem", function()
+ local found, reason = fixture_or_skip()
+ if not found then
+ return "skip", reason
+ end
+ local planner = found.by_name.planner
+ assert(planner, "a nested profile was not discovered")
+ assert(planner.description == "", "a bodyless header means an empty description")
+ assert(planner.body == "You plan.\n", string.format("%q", planner.body))
+ assert(found.by_name["from-frontmatter"], "`name` should override the stem")
+ assert(found.by_name.renamed == nil, "the stem must not survive an explicit name")
+ end },
+
+ { "the project layer shadows the user layer by name", function()
+ local found, reason = fixture_or_skip()
+ if not found then
+ return "skip", reason
+ end
+ local shared = found.by_name.shared
+ assert(shared, "shared was not discovered")
+ assert(shared.description == "from the project layer", tostring(shared.description))
+ assert(shared.body == "project body\n", string.format("%q", shared.body))
+ local count = 0
+ for _, profile in ipairs(found.list) do
+ if profile.name == "shared" then
+ count = count + 1
+ end
+ end
+ assert(count == 1, "a shadowed profile must appear once, saw " .. count)
+ end },
+
+ { "a foreign model spelling warns and inherits instead", function()
+ local found, reason = fixture_or_skip()
+ if not found then
+ return "skip", reason
+ end
+ local foreign = found.by_name.foreign
+ assert(foreign, "foreign was not discovered")
+ assert(foreign.model == nil, "an unparseable model must be dropped")
+ assert(foreign.description == "written for another harness", "the rest of the header must survive")
+ local warned = false
+ for _, warning in ipairs(found.warnings) do
+ if warning:find("ignoring model 'claude-sonnet-4'", 1, true) then
+ warned = true
+ end
+ end
+ assert(warned, "expected a warning naming the ignored model, got: " ..
+ table.concat(found.warnings, " | "))
+ end },
+
+ { "the list is sorted by name", function()
+ local found, reason = fixture_or_skip()
+ if not found then
+ return "skip", reason
+ end
+ assert(#found.list >= 5, "expected every profile in the list, saw " .. #found.list)
+ for index = 2, #found.list do
+ assert(found.list[index - 1].name < found.list[index].name,
+ "list is not sorted at " .. index)
+ end
+ end },
+}
diff --git a/spec/test_run.lua b/spec/test_run.lua
new file mode 100644
index 0000000..706faff
--- /dev/null
+++ b/spec/test_run.lua
@@ -0,0 +1,332 @@
+-- subagents/run.lua and subagents/spawn.lua: what a child is built out of, and
+-- what the model is told afterwards.
+--
+-- Every case installs a fresh fake host, so `handle.spawns` holds exactly the
+-- children this case produced: one record per child agent, carrying the
+-- resolve_model arguments, the store directory, the system messages it was
+-- seeded with, the tool declarations it was given and the run_async options.
+-- Profile sets are built inline rather than discovered: precedence, not
+-- discovery, is what these cases are about, and an explicit set also keeps the
+-- machine's real ~/.config out of the run.
+
+local fake = require("spec.fake_ext")
+local run = require("subagents.run")
+local spawn = require("subagents.spawn")
+
+local function has(text, needle)
+ assert(type(text) == "string", "expected a string result, got " .. type(text))
+ assert(text:find(needle, 1, true), "expected to find " .. needle .. " in:\n" .. tostring(text))
+end
+
+local function profile_set()
+ local reviewer = {
+ name = "reviewer",
+ description = "Reviews changes",
+ model = "anthropic:sonnet",
+ reasoning = "high",
+ body = "You are a reviewer.\n",
+ }
+ local scout = { name = "scout", description = "", body = "" }
+ return {
+ list = { reviewer, scout },
+ by_name = { reviewer = reviewer, scout = scout },
+ warnings = {},
+ }
+end
+
+-- with_host(fn, opts): install the fake, run fn(handle, profiles), restore.
+local function with_host(fn, opts)
+ local handle = fake.install(opts)
+ local ok, err = pcall(fn, handle, profile_set())
+ handle.restore()
+ if not ok then
+ error(err, 0)
+ end
+end
+
+-- The child conversation a resumed reviewer loads: the manifest on its profile
+-- system message, and the model/reasoning it last ran with on its last turn.
+local function stored_reviewer()
+ return {
+ { role = "system", text = spawn.CHILD_ROLE },
+ {
+ role = "system",
+ text = "You are a reviewer.\n",
+ metadata = { subagents = { owner = "0198-primary", agent = "reviewer" } },
+ },
+ {
+ role = "user",
+ text = "the first turn",
+ metadata = { subagents = { model = "openai:gpt-5.6", reasoning = "xhigh" } },
+ },
+ { role = "assistant", text = "first answer" },
+ }
+end
+
+return {
+ { "neither agent nor id is refused before anything is spawned", function()
+ with_host(function(handle, profiles)
+ local text = run.handle({ prompt = "do the thing" }, profiles)
+ has(text, "Error:")
+ has(text, "exactly one of `agent`")
+ assert(#handle.spawns == 0, "nothing may be spawned by a rejected call")
+ assert(not text:find("id:", 1, true), "a pre-allocation failure has no id")
+ end)
+ end },
+
+ { "both agent and id is 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")
+ assert(#handle.spawns == 0)
+ end)
+ end },
+
+ { "an empty or missing prompt is refused", function()
+ with_host(function(handle, profiles)
+ has(run.handle({ agent = "reviewer", prompt = "" }, profiles), "`prompt` must be a non-empty string")
+ has(run.handle({ agent = "reviewer", prompt = " " }, profiles), "`prompt` must be a non-empty string")
+ has(run.handle({ agent = "reviewer" }, profiles), "`prompt` must be a non-empty string")
+ assert(#handle.spawns == 0)
+ end)
+ end },
+
+ { "an unknown agent names the known profiles", function()
+ with_host(function(handle, profiles)
+ local text = run.handle({ agent = "ghost", prompt = "go" }, profiles)
+ has(text, "unknown agent 'ghost'")
+ has(text, "reviewer, scout")
+ assert(#handle.spawns == 0)
+ end)
+ end },
+
+ { "a new child is seeded with the child-role and profile system messages", function()
+ with_host(function(handle, profiles)
+ run.handle({ agent = "reviewer", prompt = "Review the auth change." }, profiles)
+ assert(#handle.spawns == 1, "expected exactly one child")
+ local child = handle.spawns[1]
+
+ assert(child.store_dir == handle.session.session_dir .. "/subagents/" .. handle.session.session_id,
+ "wrong child store dir: " .. tostring(child.store_dir))
+ assert(child.session_id == nil, "a new child must not name a session")
+ assert(child.prompt == "Review the auth change.", tostring(child.prompt))
+
+ local messages = child.system_messages
+ assert(type(messages) == "table" and #messages == 2, "expected role + profile messages, saw " .. #messages)
+ assert(messages[1].text == spawn.CHILD_ROLE, "the first message is the fixed child role")
+ assert(messages[1].metadata == nil, "the role message carries no manifest")
+ assert(messages[2].text == "You are a reviewer.\n", "the profile body is the second message")
+ local manifest = messages[2].metadata.subagents
+ assert(manifest.owner == handle.session.session_id, tostring(manifest.owner))
+ assert(manifest.agent == "reviewer", tostring(manifest.agent))
+ end)
+ end },
+
+ { "the primary's system context comes first, then the role, then the profile", 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")
+ end, {
+ primary_messages = {
+ { role = "system", text = "Project context." },
+ { role = "user", text = "the parent dialogue is never copied" },
+ { role = "assistant", text = "nor this" },
+ { role = "system", text = "House style." },
+ },
+ })
+ end },
+
+ { "the child store directory is created before the store is opened", function()
+ with_host(function(handle, profiles)
+ run.handle({ agent = "reviewer", prompt = "go" }, profiles)
+ local dir = handle.session.session_dir .. "/subagents/" .. handle.session.session_id
+ assert(handle.made_dir(dir), "the child catalog is created, not assumed: " ..
+ table.concat(handle.mkdirs, ", "))
+ assert(handle.stores[1] == dir, "the store opens on that directory: " .. tostring(handle.stores[1]))
+ end)
+ end },
+
+ { "a child inherits the primary's tools except the subagents ones", function()
+ with_host(function(handle, profiles)
+ run.handle({ agent = "reviewer", prompt = "go" }, profiles)
+ local child = handle.spawns[1]
+ assert(table.concat(child.tools, ",") == "bash,read_file",
+ "expected only the non-subagents tools, saw " .. table.concat(child.tools, ","))
+ for _, decl in ipairs(child.tool_decls) do
+ assert(decl._source == fake.SOURCE, "the re-registration tag must be passed through untouched")
+ end
+ end)
+ end },
+
+ { "the resolved model and reasoning are recorded on the turn", function()
+ with_host(function(handle, profiles)
+ run.handle({ agent = "reviewer", prompt = "go" }, profiles)
+ local metadata = handle.spawns[1].run.metadata
+ assert(type(metadata) == "table" and type(metadata.subagents) == "table",
+ "every child turn records what it ran on")
+ assert(metadata.subagents.model == "anthropic:sonnet", tostring(metadata.subagents.model))
+ assert(metadata.subagents.reasoning == "high", tostring(metadata.subagents.reasoning))
+ assert(handle.spawns[1].run.dispatch_tools ~= false, "an ordinary child dispatches its tools")
+ end)
+ end },
+
+ { "the child-role instruction states the contract the design fixes", function()
+ local role = spawn.CHILD_ROLE
+ has(role, "You are a subagent")
+ has(role, "self-contained report")
+ has(role, "verbatim")
+ has(role, "cannot ask the user questions")
+ end },
+
+ { "model and reasoning resolve tool over profile over inherited", function()
+ with_host(function(handle, profiles)
+ run.handle({ agent = "reviewer", prompt = "a" }, profiles)
+ assert(handle.spawns[1].model == "anthropic:sonnet", "the profile model applies")
+ assert(handle.spawns[1].reasoning == "high", "the profile reasoning applies")
+
+ run.handle({ agent = "reviewer", prompt = "b", model = "openai:gpt-5.6", reasoning = "xhigh" }, profiles)
+ assert(handle.spawns[2].model == "openai:gpt-5.6", "the call overrides the profile")
+ assert(handle.spawns[2].reasoning == "xhigh", "the call overrides the profile")
+
+ run.handle({ agent = "reviewer", prompt = "d", reasoning = "low" }, profiles)
+ assert(handle.spawns[3].model == "anthropic:sonnet", "model and reasoning resolve independently")
+ assert(handle.spawns[3].reasoning == "low")
+ end)
+ end },
+
+ { "a profile that names neither inherits the primary's pair", function()
+ with_host(function(handle, profiles)
+ run.handle({ agent = "scout", prompt = "look around" }, profiles)
+ assert(handle.spawns[1].model == "openai:gpt-5.6", tostring(handle.spawns[1].model))
+ assert(handle.spawns[1].reasoning == "low", tostring(handle.spawns[1].reasoning))
+ end, { session = { model = "openai:gpt-5.6", reasoning = "low" } })
+ end },
+
+ { "a profile with an empty body contributes no system message", function()
+ with_host(function(handle, profiles)
+ run.handle({ agent = "scout", prompt = "look around" }, profiles)
+ local messages = handle.spawns[1].system_messages
+ assert(#messages == 1, "expected only the child-role message, saw " .. #messages)
+ assert(messages[1].text == spawn.CHILD_ROLE)
+ end)
+ end },
+
+ { "a resume loads the stored conversation and seeds nothing", function()
+ with_host(function(handle, profiles)
+ handle.add_session("0198-child", stored_reviewer())
+ handle.queue({ output = "second turn" })
+
+ local text = run.handle({ id = "0198-child", prompt = "now the tests" }, profiles)
+ local child = handle.spawns[1]
+ assert(child.session_id == "0198-child", tostring(child.session_id))
+ assert(child.resumed, "the agent is built on the resolved session")
+ assert(#child.system_messages == 0, "the stored conversation is canonical on resume")
+ assert(child.store_dir == handle.session.session_dir .. "/subagents/" .. handle.session.session_id)
+ has(text, "id: 0198-child")
+ has(text, "second turn")
+ end)
+ end },
+
+ { "a resumed child keeps the model and reasoning of its last turn", function()
+ with_host(function(handle, profiles)
+ handle.add_session("0198-child", stored_reviewer())
+ handle.queue({})
+ run.handle({ id = "0198-child", prompt = "carry on" }, profiles)
+ assert(handle.spawns[1].model == "openai:gpt-5.6", tostring(handle.spawns[1].model))
+ assert(handle.spawns[1].reasoning == "xhigh", tostring(handle.spawns[1].reasoning))
+ end)
+ end },
+
+ { "a per-turn override beats the stored default and only where it is given", function()
+ with_host(function(handle, profiles)
+ handle.add_session("0198-child", stored_reviewer())
+ handle.queue({})
+ run.handle({ id = "0198-child", prompt = "carry on", reasoning = "high" }, profiles)
+ assert(handle.spawns[1].reasoning == "high", "the call overrides the stored default")
+ assert(handle.spawns[1].model == "openai:gpt-5.6", "the model keeps its last effective value")
+ end)
+ end },
+
+ { "a resumed child takes its agent name from the manifest", function()
+ with_host(function(handle, profiles)
+ handle.add_session("0198-child", stored_reviewer())
+ handle.queue({ output = "done" })
+ local text = run.handle({ id = "0198-child", prompt = "carry on" }, profiles)
+ has(text, "agent: reviewer")
+ end)
+ end },
+
+ { "an id this session never started is refused before anything is built", function()
+ with_host(function(handle, profiles)
+ local text = run.handle({ id = "0198-nope", prompt = "carry on" }, profiles)
+ has(text, "Error:")
+ has(text, "unknown subagent id '0198-nope'")
+ assert(#handle.spawns == 0, "no child is built for an id that does not resolve")
+ assert(#handle.runs == 0, "and no turn is started")
+ end)
+ end },
+
+ { "a completed result renders every field", function()
+ with_host(function(handle, profiles)
+ handle.queue({ id = "0198-abc", status = "completed", output = "Found two issues." })
+ local text = run.handle({ agent = "reviewer", prompt = "review" }, profiles)
+ assert(text == table.concat({
+ "id: 0198-abc",
+ "agent: reviewer",
+ "status: completed",
+ "resumable: true",
+ "--- output ---",
+ "Found two issues.",
+ }, "\n"), "unexpected block:\n" .. text)
+ end)
+ end },
+
+ { "a failed first turn reports the error and is not resumable", function()
+ with_host(function(handle, profiles)
+ handle.queue({ id = "0198-def", status = "failed", error = "provider refused", resumable = false })
+ local text = run.handle({ agent = "reviewer", prompt = "review" }, profiles)
+ has(text, "status: failed")
+ has(text, "resumable: false")
+ has(text, "provider refused")
+ end)
+ end },
+
+ { "resumability is read back from the store after the turn settles", function()
+ with_host(function(handle, profiles)
+ handle.queue({ id = "0198-ghi", output = "wrote something" })
+ has(run.handle({ agent = "reviewer", prompt = "review" }, profiles), "resumable: true")
+ assert(handle.sessions["0198-ghi"], "a durable child leaves a session behind")
+
+ handle.queue({ id = "0198-jkl", output = "died young", resumable = false })
+ has(run.handle({ agent = "reviewer", prompt = "review" }, profiles), "resumable: false")
+ assert(handle.sessions["0198-jkl"] == nil, "no file, no continuation")
+ end)
+ end },
+
+ { "a cancelled child settles as cancelled", function()
+ with_host(function(handle, profiles)
+ handle.queue({ id = "0198-ghi", status = "cancelled", error = "cancelled by the user" })
+ local text = run.handle({ agent = "reviewer", prompt = "review" }, profiles)
+ has(text, "status: cancelled")
+ has(text, "cancelled by the user")
+ end)
+ end },
+
+ { "a model the host cannot resolve is reported and starts nothing", function()
+ with_host(function(handle, profiles)
+ local text = run.handle({ agent = "reviewer", prompt = "review", model = "openai:ghost" }, profiles)
+ assert(text == "Error: resolve_model: unknown model 'openai:ghost'", text)
+ assert(#handle.resolves == 1, "the host decides what a model reference means, not the rock")
+ assert(handle.resolves[1].model == "openai:ghost", tostring(handle.resolves[1].model))
+ assert(#handle.spawns == 0, "the child is never built")
+ assert(#handle.runs == 0, "a rejected child is never started")
+ end, { unknown_models = { ["openai:ghost"] = true } })
+ end },
+}
diff --git a/spec/test_toml_workflows.lua b/spec/test_toml_workflows.lua
new file mode 100644
index 0000000..8ae2cac
--- /dev/null
+++ b/spec/test_toml_workflows.lua
@@ -0,0 +1,404 @@
+-- subagents/toml_workflows.lua: validation, dependency prompt assembly, branch
+-- failure, terminal ordering, discovery, and the generated commands.
+--
+-- Validation and execution run off definitions built in Lua, so they exercise
+-- the DAG rules without needing the TOML rock. The parse, discovery, and command
+-- cases do need toml2lua (and luv, for the recursive walk) and skip without them.
+--
+-- Discovery is pointed at temporary directories by replacing paths.config_roots
+-- for the duration of the case: the two layers, in the same order the extension
+-- uses, without reading the machine's real ~/.config.
+
+local fake = require("spec.fake_ext")
+local paths = require("subagents.paths")
+local toml_workflows = require("subagents.toml_workflows")
+
+local function has(text, needle)
+ assert(type(text) == "string", "expected a string, got " .. type(text))
+ assert(text:find(needle, 1, true), "expected to find " .. needle .. " in:\n" .. tostring(text))
+end
+
+local function profile_set()
+ local set = { list = {}, by_name = {}, warnings = {} }
+ for _, name in ipairs({ "alpha", "beta", "gamma" }) do
+ local profile = { name = name, description = name, body = "You are " .. name .. ".\n" }
+ set.list[#set.list + 1] = profile
+ set.by_name[name] = profile
+ end
+ return set
+end
+
+local function with_host(fn)
+ local handle = fake.install()
+ local ok, err = pcall(fn, handle, profile_set())
+ handle.restore()
+ if not ok then
+ error(err, 0)
+ end
+end
+
+local function write(path, text)
+ assert(os.execute("mkdir -p " .. (path:match("^(.*)/[^/]+$") or ".")))
+ local file = assert(io.open(path, "w"))
+ file:write(text)
+ file:close()
+end
+
+-- Point discovery at two temporary layers for the duration of `fn`.
+local function with_layers(files, fn)
+ local ok_uv, uv = pcall(require, "luv")
+ if not ok_uv then
+ return "skip", "luv is not installed"
+ end
+ if not pcall(require, "toml") then
+ return "skip", "toml2lua is not installed"
+ end
+
+ local tmp = assert(uv.fs_mkdtemp("/tmp/panto-subagents-workflows-XXXXXX"))
+ for name, text in pairs(files) do
+ write(tmp .. "/" .. name, text)
+ end
+
+ local original = paths.config_roots
+ paths.config_roots = function(kind)
+ return { tmp .. "/user/" .. kind, tmp .. "/project/" .. kind }
+ end
+ local ok, err = pcall(fn, tmp)
+ paths.config_roots = original
+ os.execute("rm -rf " .. tmp)
+ if not ok then
+ error(err, 0)
+ end
+end
+
+local BRANCH_DEF = {
+ name = "branch",
+ steps = {
+ { id = "a", agent = "alpha", prompt = "Inspect." },
+ { id = "b", agent = "beta", prompt = "Summarize.", needs = { "a" } },
+ { id = "c", agent = "gamma", prompt = "Unrelated." },
+ },
+}
+
+return {
+ { "a valid definition normalizes and marks its terminal steps", function()
+ local def = assert(toml_workflows.validate(BRANCH_DEF, "fallback"))
+ assert(def.name == "branch")
+ assert(#def.steps == 3)
+ assert(def.terminal.b and def.terminal.c, "b and c are nobody's dependency")
+ assert(def.terminal.a == nil, "a is depended on, so it is not terminal")
+ assert(def.by_id.b.needs[1] == "a")
+ 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))
+ end },
+
+ { "structural mistakes are refused with a readable reason", function()
+ local function bad(def, needle)
+ local ok, err = toml_workflows.validate(def, "w")
+ assert(ok == nil, "expected a rejection for " .. needle)
+ has(err, needle)
+ end
+
+ bad({ steps = {} }, "has no `steps` array")
+ bad({ steps = "nope" }, "has no `steps` array")
+ 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 = {
+ { id = "a", agent = "alpha", prompt = "p" },
+ { id = "a", agent = "beta", prompt = "q" },
+ } }, "duplicate step id 'a'")
+ bad({ steps = {
+ { id = "a", agent = "alpha", prompt = "p", needs = { "ghost" } },
+ } }, "unknown dependency 'ghost'")
+ bad({ steps = {
+ { id = "a", agent = "alpha", prompt = "p", needs = { "b" } },
+ { id = "b", agent = "beta", prompt = "q", needs = { "a" } },
+ } }, "dependency cycle")
+ end },
+
+ { "a step's prompt carries the input and each dependency in needs order", function()
+ local step = { id = "b", agent = "beta", prompt = "Summarize.", needs = { "a", "z" } }
+ local settled = {
+ a = { status = "completed", output = "A output" },
+ z = { status = "failed", error = "boom" },
+ }
+ local prompt = toml_workflows.step_prompt(step, "the workflow input", settled)
+ assert(prompt == table.concat({
+ "Summarize.",
+ "",
+ "## Workflow input",
+ "",
+ "the workflow input",
+ "",
+ "## Output of a",
+ "",
+ "A output",
+ "",
+ "## Output of z",
+ "",
+ "[failed: boom]",
+ }, "\n"), string.format("unexpected prompt:\n%s", prompt))
+ end },
+
+ { "a dependent receives its dependency's output and runs after it", 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" } },
+ },
+ }, "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 ran")
+ assert(handle.spawns[1].label == "alpha", "the root starts first")
+ has(handle.spawns[2].prompt, "## Workflow input\n\nthe input")
+ has(handle.spawns[2].prompt, "## Output of a\n\nA output")
+ assert(#results == 1 and results[1].id == "b", "only the terminal step is returned")
+ assert(results[1].output == "B 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"))
+ handle.queue_for("alpha", { status = "failed", error = "alpha died", settle = 1 })
+ handle.queue_for("gamma", { output = "gamma output", settle = 2 })
+
+ local results = toml_workflows.run(def, "input", profiles)
+
+ assert(#handle.spawns == 2, "the skipped step never spawns")
+ for _, spec in ipairs(handle.spawns) do
+ assert(spec.label ~= "beta", "beta depends on a failure and must not run")
+ end
+ assert(#results == 2, "both terminal steps are reported")
+ assert(results[1].id == "b" and results[1].status == "skipped", "terminals keep declaration order")
+ has(results[1].error, "a dependency did not complete")
+ assert(results[2].id == "c" and results[2].status == "completed")
+ assert(results[2].output == "gamma output")
+ end)
+ end },
+
+ { "independent roots start together", function()
+ with_host(function(handle, profiles)
+ local def = assert(toml_workflows.validate({
+ name = "fan",
+ steps = {
+ { id = "a", agent = "alpha", prompt = "A" },
+ { id = "c", agent = "gamma", prompt = "C" },
+ { id = "b", agent = "beta", prompt = "B", needs = { "a", "c" } },
+ },
+ }, "fan"))
+ handle.queue_for("alpha", { output = "A out", settle = 2 })
+ handle.queue_for("gamma", { output = "C out", settle = 1 })
+ handle.queue_for("beta", { output = "B out" })
+
+ local results = toml_workflows.run(def, "input", profiles)
+ assert(handle.max_live == 2, "both roots were in flight at once, peaked at " .. handle.max_live)
+ has(handle.spawns[3].prompt, "## Output of a\n\nA out")
+ has(handle.spawns[3].prompt, "## Output of c\n\nC out")
+ assert(#results == 1 and results[1].id == "b" and results[1].status == "completed")
+ end)
+ end },
+
+ { "TOML parses into the same definition shape", function()
+ if not pcall(require, "toml") then
+ return "skip", "toml2lua is not installed"
+ end
+ local def, err = toml_workflows.parse(table.concat({
+ 'name = "review-chain"',
+ 'description = "Two angles, then a synthesis."',
+ '',
+ '[[steps]]',
+ 'id = "correctness"',
+ 'agent = "alpha"',
+ 'prompt = "Review for correctness."',
+ '',
+ '[[steps]]',
+ 'id = "synthesis"',
+ 'agent = "beta"',
+ 'prompt = "Synthesize."',
+ 'reasoning = "high"',
+ 'needs = ["correctness"]',
+ }, "\n"), "stem")
+ assert(def, tostring(err))
+ assert(def.name == "review-chain")
+ assert(def.description == "Two angles, then a synthesis.")
+ assert(#def.steps == 2)
+ assert(def.steps[2].reasoning == "high")
+ assert(def.terminal.synthesis and def.terminal.correctness == nil)
+
+ local bad, bad_err = toml_workflows.parse("name = = broken", "stem")
+ assert(bad == nil, "malformed TOML must not parse")
+ has(bad_err, "invalid TOML")
+ end },
+
+ { "discovery shadows by name and registers one command per valid workflow", function()
+ local valid = table.concat({
+ 'name = "review-chain"',
+ 'description = "Inspect a change from two angles."',
+ '[[steps]]',
+ 'id = "one"',
+ 'agent = "alpha"',
+ 'prompt = "Look."',
+ }, "\n")
+ local user_shadowed = table.concat({
+ 'name = "shadowed"',
+ 'description = "the user layer"',
+ '[[steps]]',
+ 'id = "one"',
+ 'agent = "alpha"',
+ 'prompt = "user"',
+ }, "\n")
+ local project_shadowed = table.concat({
+ 'name = "shadowed"',
+ 'description = "the project layer"',
+ '[[steps]]',
+ 'id = "one"',
+ 'agent = "beta"',
+ 'prompt = "project"',
+ }, "\n")
+ local broken = table.concat({
+ '[[steps]]',
+ 'id = "dup"',
+ 'agent = "alpha"',
+ 'prompt = "one"',
+ '[[steps]]',
+ 'id = "dup"',
+ 'agent = "alpha"',
+ 'prompt = "two"',
+ }, "\n")
+
+ return with_layers({
+ ["user/workflows/review-chain.toml"] = valid,
+ ["user/workflows/nested/shadowed.toml"] = user_shadowed,
+ ["project/workflows/shadowed.toml"] = project_shadowed,
+ ["project/workflows/broken.toml"] = broken,
+ }, function()
+ with_host(function(handle, profiles)
+ local found = toml_workflows.discover_and_register(profiles)
+
+ assert(handle.commands_by_name["workflow:review-chain"], "a valid workflow registers a command")
+ assert(handle.commands_by_name["workflow:review-chain"].description ==
+ "Inspect a change from two angles.", "the description comes from the file")
+ assert(handle.commands_by_name["workflow:shadowed"].description == "the project layer",
+ "the project layer shadows the user layer")
+ assert(handle.commands_by_name["workflow:broken"] == nil, "an invalid file registers nothing")
+ assert(#handle.commands == 2, "exactly two commands, saw " .. #handle.commands)
+
+ local warned = false
+ for _, warning in ipairs(found.warnings) do
+ if warning:find("duplicate step id 'dup'", 1, true) then
+ warned = true
+ end
+ end
+ assert(warned, "the invalid file's error is kept: " .. table.concat(found.warnings, " | "))
+
+ -- The command tail becomes the workflow input.
+ handle.queue_for("alpha", { output = "looked" })
+ local text = handle.commands_by_name["workflow:review-chain"].handler("check the parser")
+ has(text, "step: one")
+ has(text, "status: completed")
+ has(text, "looked")
+ has(handle.spawns[1].prompt, "## Workflow input\n\ncheck the parser")
+
+ -- And the tool can run the same workflow, or explain a broken one.
+ has(toml_workflows.handle({ name = "broken", prompt = "x" }, profiles),
+ "failed to load")
+ has(toml_workflows.handle({ name = "ghost", prompt = "x" }, profiles),
+ "unknown workflow 'ghost'")
+ end)
+ end)
+ end },
+
+ { "a broken project workflow shadows the user one under its declared name", function()
+ local user_valid = table.concat({
+ 'name = "dup"',
+ 'description = "the user layer"',
+ '[[steps]]',
+ 'id = "one"',
+ 'agent = "alpha"',
+ 'prompt = "user"',
+ }, "\n")
+ -- Declares the same name from a differently-named file, and is invalid.
+ local project_broken = table.concat({
+ 'name = "dup"',
+ '[[steps]]',
+ 'id = "same"',
+ 'agent = "alpha"',
+ 'prompt = "one"',
+ '[[steps]]',
+ 'id = "same"',
+ 'agent = "alpha"',
+ 'prompt = "two"',
+ }, "\n")
+
+ return with_layers({
+ ["user/workflows/dup.toml"] = user_valid,
+ ["project/workflows/whatever.toml"] = project_broken,
+ }, function()
+ with_host(function(handle, profiles)
+ local found = toml_workflows.discover_and_register(profiles)
+
+ local entry = found.by_name["dup"]
+ assert(entry, "the project error must be indexed under the name it declares")
+ assert(entry.definition == nil, "the shadowed user definition must not survive")
+ has(entry.error, "duplicate step id 'same'")
+ assert(#handle.commands == 0, "a shadowed-out workflow registers no command")
+ has(toml_workflows.handle({ name = "dup", prompt = "x" }, profiles), "failed to load")
+ end)
+ end)
+ end },
+
+ { "a command whose workflow cannot run reports the error, not a stack trace", function()
+ with_host(function(handle, profiles)
+ local def = assert(toml_workflows.validate({
+ name = "ghosted",
+ steps = { { id = "one", agent = "nobody", prompt = "Do it." } },
+ }, "ghosted"))
+ 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()
+ 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({ name = "a" }, profiles), "prompt is required")
+ assert(#handle.spawns == 0)
+ end)
+ end },
+
+ { "the tool runs a transient definition", function()
+ with_host(function(handle, profiles)
+ handle.queue_for("alpha", { output = "transient output" })
+ 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")
+
+ has(toml_workflows.handle({
+ prompt = "x",
+ steps = { { id = "only", agent = "alpha", prompt = "p", needs = { "ghost" } } },
+ }, profiles), "unknown dependency 'ghost'")
+ end)
+ end },
+}
diff --git a/spec/test_workflow.lua b/spec/test_workflow.lua
new file mode 100644
index 0000000..c5ba06c
--- /dev/null
+++ b/spec/test_workflow.lua
@@ -0,0 +1,299 @@
+-- subagents/workflow.lua: the callback API's awaiting, failure-as-value rule,
+-- structured workers, the concurrency gate, and the job budget.
+--
+-- The fake host settles jobs in the order each outcome's `settle` key asks for,
+-- which is what makes the ordering cases meaningful: input order and settle
+-- order are deliberately different everywhere below.
+
+local fake = require("spec.fake_ext")
+local workflow = require("subagents.workflow")
+
+local function has(text, needle)
+ assert(type(text) == "string", "expected a string, got " .. type(text))
+ assert(text:find(needle, 1, true), "expected to find " .. needle .. " in:\n" .. tostring(text))
+end
+
+local function profile_set()
+ local set = { list = {}, by_name = {}, warnings = {} }
+ for _, name in ipairs({ "alpha", "beta", "gamma" }) do
+ local profile = { name = name, description = name, body = "You are " .. name .. ".\n" }
+ set.list[#set.list + 1] = profile
+ set.by_name[name] = profile
+ end
+ return set
+end
+
+local function with_host(fn, opts)
+ local handle = fake.install(opts)
+ local ok, err = pcall(fn, handle, profile_set())
+ handle.restore()
+ if not ok then
+ error(err, 0)
+ end
+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 })
+ end
+ return handles
+end
+
+local function json_available()
+ return pcall(require, "dkjson")
+end
+
+local ITEMS_SCHEMA = {
+ type = "object",
+ required = { "items" },
+ properties = {
+ items = { type = "array", items = { type = "string" } },
+ },
+}
+
+local GHOST = { unknown_models = { ["openai:ghost"] = true } }
+
+return {
+ { "await all returns results in input order, not settle order", function()
+ with_host(function(handle, profiles)
+ handle.queue_for("alpha", { output = "A", settle = 3 })
+ handle.queue_for("beta", { output = "B", settle = 1 })
+ handle.queue_for("gamma", { output = "C", settle = 2 })
+
+ local results = workflow.execute(workflow.workflow(function(ctx)
+ return ctx:await(three_children(ctx), "all")
+ end), "input", { profiles = profiles })
+
+ assert(#results == 3, "expected three results")
+ assert(results[1].output == "A", tostring(results[1].output))
+ assert(results[2].output == "B", tostring(results[2].output))
+ assert(results[3].output == "C", tostring(results[3].output))
+ assert(#handle.spawns == 3, "one child per ctx:agent")
+ assert(handle.spawns[1].prompt == "work on alpha")
+ end)
+ end },
+
+ { "await first returns the earliest settler and the remaining handles", function()
+ with_host(function(handle, profiles)
+ handle.queue_for("alpha", { output = "A", settle = 3 })
+ handle.queue_for("beta", { output = "B", settle = 1 })
+ handle.queue_for("gamma", { output = "C", settle = 2 })
+
+ local order = workflow.execute(workflow.workflow(function(ctx)
+ local handles = three_children(ctx)
+ local seen = {}
+ while #handles > 0 do
+ local result, remaining = ctx:await(handles, "first")
+ seen[#seen + 1] = result.output
+ assert(#remaining == #handles - 1, "one handle settles per await")
+ handles = remaining
+ end
+ return seen
+ end), "input", { profiles = profiles })
+
+ assert(table.concat(order, ",") == "B,C,A", table.concat(order, ","))
+ end)
+ end },
+
+ { "handle:await settles one child", function()
+ 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()
+ 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()
+ 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 })
+ end
+ assert(#handle.runs == 4, "four 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)
+ end)
+ end },
+
+ { "a rejected child is a failed result, not an error", function()
+ with_host(function(handle, profiles)
+ 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" })
+ return ctx:await({ first, second }, "all")
+ end), "input", { profiles = profiles })
+
+ assert(results[1].status == "failed", tostring(results[1].status))
+ has(results[1].error, "unknown model 'openai:ghost'")
+ assert(results[1].resumable == false, "a child that never allocated is not resumable")
+ assert(results[2].status == "completed", "a sibling failure must not disturb this one")
+ assert(#handle.runs == 1, "only the sibling was ever started")
+ end, GHOST)
+ end },
+
+ { "a failed child is reported as a value", function()
+ 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()
+ end), "input", { profiles = profiles })
+ assert(result.status == "failed")
+ assert(result.error == "provider refused", tostring(result.error))
+ end)
+ end },
+
+ { "an id with a turn already in flight is refused", function()
+ with_host(function(handle, profiles)
+ handle.add_session("0198-child", {
+ { role = "system", text = "You are alpha.\n",
+ metadata = { subagents = { owner = "0198-primary", agent = "alpha" } } },
+ })
+ 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" })
+ return ctx:await({ first, second }, "all")
+ end), "input", { profiles = profiles })
+
+ assert(results[1].status == "completed", tostring(results[1].error))
+ assert(results[2].status == "failed", "one turn per child at a time")
+ has(results[2].error, "already has a turn in flight")
+ assert(#handle.runs == 1, "the second call never starts a turn")
+ end)
+ end },
+
+ { "a structured worker decodes and validates its output", function()
+ if not json_available() then
+ return "skip", "dkjson is not installed"
+ end
+ with_host(function(handle, profiles)
+ handle.queue_for("alpha", { structured_json = '{"items":["x","y"]}' })
+ local result = workflow.execute(workflow.workflow(function(ctx)
+ return ctx:agent({
+ agent = "alpha",
+ prompt = "split it",
+ output = { description = "Return the work items.", schema = ITEMS_SCHEMA },
+ }):await()
+ end), "input", { profiles = profiles })
+
+ assert(result.status == "completed", tostring(result.error))
+ assert(type(result.output) == "table", "structured output decodes to a table")
+ assert(result.output.items[1] == "x" and result.output.items[2] == "y")
+ assert(result.resumable == false, "a one-shot child is not resumable")
+ assert(result.id == nil, "and has no durable session to name")
+ assert(#handle.stores == 0, "a structured worker never touches the child catalog")
+
+ local child = handle.spawns[1]
+ assert(type(child.tool_choice) == "table" and child.tool_choice.name == "emit_result",
+ "the output tool is the only choice the child has")
+ assert(child.run.dispatch_tools == false, "a one-shot child never dispatches a tool call")
+ assert(#child.tool_decls == 1, "the output tool replaces the inherited set, saw " ..
+ table.concat(child.tools, ","))
+
+ local decl = child.tool_decls[1]
+ assert(decl.name == "emit_result", "the synthetic tool is named for the host")
+ assert(decl.description == "Return the work items.")
+ assert(decl.schema == ITEMS_SCHEMA, "the schema is passed through untouched")
+ assert(decl._source == nil and decl._ctx == nil and decl._vt == nil,
+ "the output tool is declaration-only: nothing may dispatch it")
+ end)
+ end },
+
+ { "structured output that violates the schema fails the result", function()
+ if not json_available() then
+ return "skip", "dkjson is not installed"
+ end
+ with_host(function(handle, profiles)
+ handle.queue_for("alpha", { structured_json = '{"nope":1}' })
+ local result = workflow.execute(workflow.workflow(function(ctx)
+ return ctx:agent({
+ agent = "alpha",
+ prompt = "split it",
+ output = { schema = ITEMS_SCHEMA },
+ }):await()
+ end), "input", { profiles = profiles })
+
+ assert(result.status == "failed", "a schema violation is never a success")
+ has(result.error, "structured output failed validation")
+ assert(result.output == nil, "invalid output is not handed to the caller")
+ end)
+ end },
+
+ { "a structured worker that answers in prose fails", function()
+ with_host(function(handle, profiles)
+ handle.queue_for("alpha", { output = "prose, not a tool call" })
+ local result = workflow.execute(workflow.workflow(function(ctx)
+ return ctx:agent({
+ agent = "alpha",
+ prompt = "split it",
+ output = { schema = ITEMS_SCHEMA },
+ }):await()
+ end), "input", { profiles = profiles })
+ assert(result.status == "failed", tostring(result.status))
+ has(result.error, "did not call the required 'emit_result' output tool")
+ end)
+ end },
+
+ { "an already settled handle is served without polling again", function()
+ with_host(function(handle, profiles)
+ handle.queue_for("beta", { output = "B" })
+ 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 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")
+ return result.status
+ end), "input", { profiles = profiles })
+
+ assert(first_status == "failed", tostring(first_status))
+ assert(#handle.runs == 1, "only the live sibling was ever started")
+ end, GHOST)
+ end },
+
+ { "max_jobs caps how many children one workflow starts", function()
+ with_host(function(handle, profiles)
+ local ok, err = pcall(workflow.execute, workflow.workflow(function(ctx)
+ three_children(ctx)
+ end), "input", { profiles = profiles, max_jobs = 2 })
+ assert(not ok, "the third ctx:agent must be refused")
+ has(tostring(err), "job limit exceeded")
+ assert(#handle.runs == 2, "the capped call never reaches the host")
+ end)
+ end },
+
+ { "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" })
+ return "done"
+ end), "input", { profiles = profiles })
+ assert(#handle.jobs == 1, "one child ran")
+ assert(handle.jobs[1]._settled, "no child is left running")
+ end)
+ end },
+
+ { "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" })
+ end), "input", { profiles = profiles })
+ assert(not ok, "an unknown profile is a programmer error, not a child failure")
+ has(tostring(err), "unknown agent 'ghost'")
+ end)
+ end },
+}
diff --git a/subagents/frontmatter.lua b/subagents/frontmatter.lua
new file mode 100644
index 0000000..94fe2ac
--- /dev/null
+++ b/subagents/frontmatter.lua
@@ -0,0 +1,96 @@
+-- Split a Markdown profile into its YAML frontmatter and its body.
+--
+-- The format is the common one other agent harnesses use: if the very first
+-- line of the file is exactly `---`, everything up to the next line that is
+-- exactly `---` is a YAML mapping, and everything after that closing fence is
+-- the body. Trailing carriage returns are tolerated so CRLF files parse.
+--
+-- Anything unusual degrades to "no metadata, body only" with a warning rather
+-- than an error, because the body is the part the user cannot afford to lose:
+--
+-- * no opening fence -> the whole file is the body, no warning
+-- * unterminated opening fence-> the whole file is the body, no warning
+-- (a lone `---` at the top of a prose file is a horizontal rule, not a
+-- broken header, so this case is deliberately silent)
+-- * empty fenced block -> empty mapping, no warning
+-- * lyaml missing or erroring -> body after the fence, warning returned
+-- * YAML document not a map -> body after the fence, warning returned
+--
+-- In the warning cases the fenced block is dropped rather than folded back
+-- into the body: an unparseable header is noise the child agent should not be
+-- asked to read. The body itself is never rewritten — no trimming, no
+-- normalisation — so a prompt round-trips verbatim.
+
+local M = {}
+
+local function trim(s)
+ return (s:gsub("^%s+", ""):gsub("%s+$", ""))
+end
+
+-- Iterate lines, yielding the line plus its start offset and the offset just
+-- past its newline, so the caller can slice the original text exactly.
+local function lines(text)
+ local pos = 1
+ return function()
+ if pos > #text then
+ return nil
+ end
+ local start = pos
+ local nl = text:find("\n", pos, true)
+ local line
+ if nl then
+ line = text:sub(start, nl - 1)
+ pos = nl + 1
+ else
+ line = text:sub(start)
+ pos = #text + 1
+ end
+ return line, start, pos
+ end
+end
+
+-- parse(text) -> data|nil, body, warning|nil
+function M.parse(text)
+ if type(text) ~= "string" or text == "" then
+ return nil, "", nil
+ end
+
+ local next_line = lines(text)
+ local first, _, after_first = next_line()
+ if first == nil or trim(first) ~= "---" then
+ return nil, text, nil
+ end
+
+ local block_stop, body_start
+ for line, start, after in next_line do
+ if trim(line) == "---" then
+ block_stop = start - 1
+ body_start = after
+ break
+ end
+ end
+ if body_start == nil then
+ return nil, text, nil
+ end
+
+ local block = text:sub(after_first, block_stop)
+ local body = text:sub(body_start)
+ if trim(block) == "" then
+ return {}, body, nil
+ end
+
+ local ok_lyaml, lyaml = pcall(require, "lyaml")
+ if not ok_lyaml then
+ return nil, body, "lyaml is not installed; ignoring the YAML frontmatter"
+ end
+ local ok, data = pcall(lyaml.load, block)
+ if not ok then
+ return nil, body, "YAML frontmatter did not parse: " .. tostring(data)
+ end
+ if type(data) ~= "table" then
+ return nil, body, "YAML frontmatter is not a mapping; ignoring it"
+ end
+ return data, body, nil
+end
+
+return M
diff --git a/subagents/jobs.lua b/subagents/jobs.lua
new file mode 100644
index 0000000..a8cde6d
--- /dev/null
+++ b/subagents/jobs.lua
@@ -0,0 +1,427 @@
+-- Start child agent jobs, bound how many run at once, and drain their events.
+--
+-- One module owns the session-wide concurrency bound and the event pump, so
+-- the policy that decides *what* to start (subagents/spawn.lua) never touches
+-- luv, and the callers that wait for a result (subagents/run.lua, the workflow
+-- API) never touch a job.
+--
+-- Threading and ownership: everything here runs on panto's Lua owner thread.
+-- A job's pump runs on its own thread inside the binding; it buffers events on
+-- the job and writes one byte to the wake pipe handed to it here. That byte is
+-- 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. Where there is no coroutine
+-- to park (a plain script) or a job with no wake pipe, the identical drain
+-- runs in a loop instead — same gate, same settle bookkeeping, only the wait
+-- differs.
+--
+-- A settled result is read and cached the moment it appears, because
+-- `job:close()` frees it. Jobs are otherwise left open until close_all() ends
+-- the turn, so a result can still be read after its coroutine resumed.
+
+local ok_uv, uv = pcall(require, "luv")
+
+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
+
+local handle_mt = {}
+handle_mt.__index = handle_mt
+handle_mt.__name = "subagents.job"
+
+-- Every handle that has not been closed, in start order; the queue is the
+-- subset waiting for a slot; `running` counts the started-but-unsettled ones.
+local live = {}
+local queued = {}
+local waiters = {}
+local running = 0
+local pumping = false
+
+-- ---------------------------------------------------------------------------
+-- Wake pipes
+-- ---------------------------------------------------------------------------
+
+local function open_pipe()
+ if not ok_uv or type(uv.pipe) ~= "function" then
+ return nil
+ end
+ local ok, pair = pcall(uv.pipe, { nonblock = true }, { nonblock = true })
+ if not ok or type(pair) ~= "table" then
+ return nil
+ end
+ return pair
+end
+
+-- Close the poll before the fds: the pump has already exited by the time a job
+-- settles, so nothing can be mid-write when the read end goes away.
+local function close_pipe(handle)
+ if handle.poll then
+ pcall(handle.poll.stop, handle.poll)
+ pcall(handle.poll.close, handle.poll)
+ handle.poll = nil
+ end
+ if handle.fds then
+ pcall(uv.fs_close, handle.fds.read)
+ pcall(uv.fs_close, handle.fds.write)
+ handle.fds = nil
+ end
+end
+
+local function drain_pipe(handle)
+ if not handle.fds then
+ return
+ end
+ while true do
+ local data = uv.fs_read(handle.fds.read, READ_CHUNK, -1)
+ if type(data) ~= "string" or #data < READ_CHUNK then
+ return
+ end
+ end
+end
+
+-- ---------------------------------------------------------------------------
+-- Gate, drain, settle
+-- ---------------------------------------------------------------------------
+
+local drain
+local pump_queue
+local wake
+
+local function settle(handle, raw)
+ if handle.settled ~= nil then
+ return
+ end
+ local result = raw
+ if handle.spec.shape then
+ local ok, shaped = pcall(handle.spec.shape, raw)
+ if ok and type(shaped) == "table" then
+ result = shaped
+ elseif not ok then
+ result = { status = "failed", error = tostring(shaped), resumable = false }
+ end
+ end
+ handle.settled = result
+ if handle.state == "running" then
+ running = running - 1
+ end
+ handle.state = "settled"
+ close_pipe(handle)
+ pump_queue()
+end
+
+local function launch(handle)
+ handle.fds = open_pipe()
+ local ok, job, err = pcall(handle.spec.build, handle.fds and handle.fds.write or nil)
+ if not ok then
+ close_pipe(handle)
+ return false, tostring(job)
+ end
+ if not job then
+ close_pipe(handle)
+ return false, err and tostring(err) or "the child could not be started"
+ end
+
+ handle.job = job
+ handle.state = "running"
+ running = running + 1
+
+ if handle.fds and ok_uv then
+ local armed, poll = pcall(uv.new_poll, handle.fds.read)
+ if armed and poll then
+ handle.poll = poll
+ poll:start("r", function()
+ drain(handle)
+ wake()
+ end)
+ end
+ end
+ return true
+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
+ return
+ end
+ pumping = true
+ while running < M.MAX_CONCURRENT do
+ local handle = table.remove(queued, 1)
+ if handle == nil then
+ break
+ end
+ if handle.state == "queued" then
+ local ok, err = launch(handle)
+ if not ok then
+ settle(handle, { status = "failed", error = tostring(err) })
+ end
+ end
+ end
+ pumping = false
+end
+
+-- Drain one job: the wake pipe, then every buffered event, then the result.
+-- Returns true when anything moved, which is what the fallback wait loop uses
+-- to decide whether it is spinning.
+function drain(handle)
+ local job = handle.job
+ if job == nil or handle.settled ~= nil then
+ return false
+ end
+ drain_pipe(handle)
+
+ local moved = false
+ local on_event = handle.spec.on_event
+ local event = job:next_event()
+ while event ~= nil do
+ moved = true
+ if on_event then
+ pcall(on_event, event)
+ end
+ event = job:next_event()
+ end
+
+ local raw = job:result()
+ if raw ~= nil then
+ settle(handle, raw)
+ return true
+ end
+ return moved
+end
+
+local function drain_all()
+ local moved = false
+ local index = 1
+ while index <= #live do
+ if drain(live[index]) then
+ moved = true
+ end
+ index = index + 1
+ end
+ return moved
+end
+
+-- ---------------------------------------------------------------------------
+-- Handles
+-- ---------------------------------------------------------------------------
+
+function handle_mt:result()
+ return self.settled
+end
+
+-- A queued child settles without its build ever running: no user message is
+-- appended, so nothing on disk claims a turn that never happened.
+function handle_mt:cancel()
+ if self.settled ~= nil then
+ return
+ end
+ if self.state == "queued" then
+ for index, other in ipairs(queued) do
+ if other == self then
+ table.remove(queued, index)
+ break
+ end
+ end
+ settle(self, { status = "cancelled", error = "cancelled before the child started" })
+ return
+ end
+ if self.job then
+ pcall(self.job.request_cancel, self.job)
+ end
+end
+
+-- start(startspec) -> handle | nil, err
+--
+-- startspec = {
+-- build = function(wake_fd) -> job | nil, err -- calls agent:run_async
+-- label = string?, id = string?, one_shot = boolean?
+-- on_event = function(event)? -- one call per drained run_async event
+-- shape = function(raw) -> result? -- maps the settled run_async result
+-- onto the caller's result table
+-- }
+--
+-- Over the bound the job is queued and `build` is not called yet.
+function M.start(spec)
+ if type(spec) ~= "table" or type(spec.build) ~= "function" then
+ return nil, "jobs.start needs a build function"
+ end
+
+ local handle = setmetatable({
+ spec = spec,
+ label = spec.label,
+ id = spec.id,
+ one_shot = spec.one_shot == true,
+ state = "queued",
+ }, handle_mt)
+ live[#live + 1] = handle
+
+ if running >= M.MAX_CONCURRENT then
+ queued[#queued + 1] = handle
+ return handle
+ end
+
+ local ok, err = launch(handle)
+ if not ok then
+ for index, other in ipairs(live) do
+ if other == handle then
+ table.remove(live, index)
+ break
+ end
+ end
+ return nil, err
+ end
+ -- Deliberately not drained here: starting a child must not settle it, and
+ -- the first wake byte is already in the pipe by the time anyone waits.
+ return handle
+end
+
+-- True while a child with this id has a turn in flight.
+function M.active(id)
+ if id == nil then
+ return false
+ end
+ for _, handle in ipairs(live) do
+ if handle.id == id and handle.settled == nil then
+ return true
+ end
+ end
+ return false
+end
+
+-- ---------------------------------------------------------------------------
+-- Awaiting
+-- ---------------------------------------------------------------------------
+
+-- "all" is satisfied only when every handle has settled; "first" as soon as one
+-- has. Both answer in input order, and "first" hands the rest back by identity
+-- so a caller can await them again.
+local function collect(handles, mode)
+ if mode == "first" then
+ local results, remaining = {}, {}
+ for _, handle in ipairs(handles) do
+ if handle.settled ~= nil then
+ results[#results + 1] = handle.settled
+ else
+ remaining[#remaining + 1] = handle
+ end
+ end
+ if #results == 0 and #remaining > 0 then
+ return nil
+ end
+ return results, remaining
+ end
+
+ local results = {}
+ for index, handle in ipairs(handles) do
+ if handle.settled == nil then
+ return nil
+ end
+ results[index] = handle.settled
+ end
+ return results, {}
+end
+
+-- Resume every parked await whose condition now holds. Never resumes the
+-- running coroutine: a coroutine that is executing cannot also be parked.
+function wake()
+ local self_co = coroutine.running()
+ local index = 1
+ while index <= #waiters do
+ local waiter = waiters[index]
+ local results, remaining = collect(waiter.handles, waiter.mode)
+ if results ~= nil and waiter.co ~= self_co and coroutine.status(waiter.co) == "suspended" then
+ table.remove(waiters, index)
+ coroutine.resume(waiter.co, results, remaining)
+ else
+ index = index + 1
+ end
+ end
+end
+
+-- Parking is only safe when something will wake us: a started job with no wake
+-- pipe is drained by asking it directly instead.
+local function can_park(handles)
+ if not coroutine.isyieldable() then
+ return false
+ end
+ for _, handle in ipairs(handles) do
+ if handle.state == "running" and handle.poll == nil then
+ return false
+ end
+ end
+ return true
+end
+
+-- await(handles, mode) -> results, remaining
+function M.await(handles, mode)
+ mode = mode or "all"
+ if type(handles) ~= "table" then
+ error("jobs.await expects an array of job handles", 2)
+ end
+ if mode ~= "all" and mode ~= "first" then
+ error("jobs.await mode must be \"all\" or \"first\"", 2)
+ end
+
+ while true do
+ local moved = drain_all()
+ local results, remaining = collect(handles, mode)
+ if results ~= nil then
+ return results, remaining
+ end
+ wake()
+
+ if can_park(handles) then
+ waiters[#waiters + 1] = { co = coroutine.running(), handles = handles, mode = mode }
+ local woken, rest = coroutine.yield()
+ if woken ~= nil then
+ return woken, rest
+ end
+ elseif not moved and ok_uv then
+ -- Nothing to drain and nothing to park on: yield the core rather
+ -- than burn it while the pump threads work.
+ uv.sleep(1)
+ end
+ end
+end
+
+-- ---------------------------------------------------------------------------
+-- Turn lifecycle
+-- ---------------------------------------------------------------------------
+
+-- 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()
+ for index = #live, 1, -1 do
+ live[index]:cancel()
+ end
+end
+
+-- The turn is over: join every pump and drop the state. Requests go out first
+-- so the joins overlap instead of running one child's teardown at a time.
+function M.close_all()
+ for _, handle in ipairs(live) do
+ if handle.job and handle.settled == nil then
+ pcall(handle.job.request_cancel, handle.job)
+ end
+ end
+ for _, handle in ipairs(live) do
+ if handle.job then
+ pcall(handle.job.close, handle.job)
+ handle.job = nil
+ end
+ close_pipe(handle)
+ handle.state = "closed"
+ end
+ live, queued, waiters = {}, {}, {}
+ running = 0
+end
+
+return M
diff --git a/subagents/luatool.lua b/subagents/luatool.lua
new file mode 100644
index 0000000..089c67b
--- /dev/null
+++ b/subagents/luatool.lua
@@ -0,0 +1,193 @@
+-- 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.
+--
+-- 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
+-- standard library plus `subagents.workflow`; it has no `os`, `io`, `debug`,
+-- `package`, `require`, `load`, `dofile`, `coroutine`, `setmetatable`, or
+-- `getmetatable`, and `print` is a no-op so generated code cannot scribble on
+-- the TUI. `string`, `table`, `math`, and `utf8` are shallow copies, so a guest
+-- that reassigns `table.insert` only breaks itself, and `string.dump` is
+-- removed from the copy.
+--
+-- `pcall`/`xpcall` are deliberately absent: they would let a guest catch the
+-- instruction-budget error and spin again in fresh 10M-instruction chunks. A
+-- guest has no need to recover from its own errors — the handler reports them —
+-- and with no `pcall`, `coroutine`, or metatable access left, nothing in the
+-- environment can trap the hook error before it reaches the host.
+--
+-- Known, accepted gaps in that sandbox:
+--
+-- * The real string metatable is still reachable through any string literal
+-- (`("").dump`), so `string.dump` is obtainable. Without `load` there is no
+-- way to turn bytecode back into a running function, so this is noise rather
+-- than an escape.
+-- * `ctx:agent` returns handles the guest can read and scribble on. Nothing
+-- reachable from one starts work: the running job — which owns the child's
+-- agent and could start turns outside the cap — lives in a private side table
+-- in workflow.lua, as do the job cap, the counter, and the profile set, so a
+-- guest holding `ctx` and its handles cannot raise its own cap or reach the
+-- host.
+--
+-- 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.
+
+local workflow = require("subagents.workflow")
+local run = require("subagents.run")
+
+local MAX_JOBS = 32
+local INSTRUCTION_BUDGET = 10000000
+local CHUNK_NAME = "subagents.lua"
+
+local M = {}
+
+M.max_jobs = MAX_JOBS
+M.instruction_budget = INSTRUCTION_BUDGET
+
+local function shallow_copy(source, skip)
+ local copy = {}
+ for key, value in pairs(source) do
+ if key ~= skip then
+ copy[key] = value
+ end
+ end
+ return copy
+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 env = {
+ assert = assert,
+ error = error,
+ ipairs = ipairs,
+ next = next,
+ pairs = pairs,
+ select = select,
+ tonumber = tonumber,
+ tostring = tostring,
+ type = type,
+ string = shallow_copy(string, "dump"),
+ table = shallow_copy(table),
+ math = shallow_copy(math),
+ utf8 = shallow_copy(utf8),
+ print = function() end,
+ subagents = { workflow = workflow.workflow },
+ }
+ env._G = env
+ return env
+end
+
+M.build_env = build_env
+
+local function budget_hook()
+ error("instruction budget exceeded", 0)
+end
+
+-- subagents.run's block formatter expects string output; a structured worker's
+-- output is a decoded table, so it is re-encoded first.
+local function format_one(result)
+ if type(result.output) == "table" then
+ local flattened = {}
+ for key, value in pairs(result) do
+ flattened[key] = value
+ end
+ flattened.output = workflow.output_text(result)
+ return run.format_result(flattened)
+ end
+ return run.format_result(result)
+end
+
+-- Format whatever the workflow callback returned. Result-shaped tables (the
+-- common case: one settled result, or an array of them) render as the same
+-- plain "key: value" block subagents.run uses; anything else is encoded
+-- compactly so the model still sees it.
+local function format_return(value)
+ if value == nil then
+ return "The workflow returned no value."
+ end
+ if type(value) ~= "table" then
+ return tostring(value)
+ end
+ if value.status ~= nil then
+ return format_one(value)
+ end
+ local blocks, count = {}, 0
+ for index, entry in ipairs(value) do
+ if type(entry) ~= "table" or entry.status == nil then
+ blocks = nil
+ break
+ end
+ blocks[index] = format_one(entry)
+ count = count + 1
+ end
+ if blocks and count > 0 then
+ return table.concat(blocks, "\n\n")
+ end
+ return workflow.json_encode(value)
+end
+
+M.format_return = format_return
+
+-- 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)
+ 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
+
+ local chunk, load_err = load(input.source, CHUNK_NAME, "t", build_env())
+ if not chunk then
+ return "Error: source did not compile: " .. tostring(load_err)
+ end
+
+ local built_ok, built = pcall(chunk)
+ 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,
+ 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)
+end
+
+return M
diff --git a/subagents/models.lua b/subagents/models.lua
new file mode 100644
index 0000000..511aa51
--- /dev/null
+++ b/subagents/models.lua
@@ -0,0 +1,233 @@
+-- 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:
+--
+-- { 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
+-- the whole registry into the conversation.
+--
+-- The host owns the catalog and the provider-specific reasoning rules,
+-- including effort levels a Lua protocol reports dynamically. Anything this
+-- 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
+
+local M = {}
+
+local function host()
+ return require("panto").ext
+end
+
+local function ask(q)
+ local ok, result = pcall(host().models, q)
+ if not ok then
+ return nil, tostring(result)
+ end
+ if type(result) ~= "table" then
+ return nil, "the host returned no model information"
+ end
+ return result
+end
+
+local function format_overview(result)
+ local out = {
+ "inherited model: " .. (result.model or "(unknown)"),
+ "inherited reasoning: " .. (result.reasoning or "(provider default)"),
+ }
+ local providers = result.providers or {}
+ if #providers == 0 then
+ out[#out + 1] = "providers: (none configured)"
+ return table.concat(out, "\n")
+ end
+ out[#out + 1] = "providers:"
+ for _, provider in ipairs(providers) do
+ out[#out + 1] = string.format(" %s (%s, %d models)",
+ tostring(provider.name), tostring(provider.style or "?"), tonumber(provider.models) or 0)
+ end
+ return table.concat(out, "\n")
+end
+
+local function format_match(match)
+ local detail = {}
+ if match.wire_model then
+ detail[#detail + 1] = "wire " .. tostring(match.wire_model)
+ end
+ if match.reasoning_default then
+ detail[#detail + 1] = "default reasoning " .. tostring(match.reasoning_default)
+ end
+ if match.context_window then
+ detail[#detail + 1] = "context " .. tostring(match.context_window)
+ end
+ if match.max_tokens then
+ detail[#detail + 1] = "max tokens " .. tostring(match.max_tokens)
+ end
+ local line = " " .. tostring(match.ref)
+ if #detail > 0 then
+ line = line .. " — " .. table.concat(detail, ", ")
+ end
+ return line
+end
+
+local function format_exact(result, ref)
+ if not result.found then
+ return string.format(
+ "No configured model matches '%s'. Search with subagents.models { query = \"...\" }.", ref)
+ end
+ local out = { "model: " .. tostring(result.ref or ref) }
+ if result.wire_model then
+ out[#out + 1] = "wire model: " .. tostring(result.wire_model)
+ end
+ out[#out + 1] = "default reasoning: " .. (result.reasoning_default or "(provider default)")
+ local levels = result.reasoning_levels
+ if type(levels) == "table" and #levels > 0 then
+ out[#out + 1] = "reasoning levels: " .. table.concat(levels, ", ")
+ end
+ if result.context_window then
+ out[#out + 1] = "context window: " .. tostring(result.context_window)
+ end
+ if result.max_tokens then
+ out[#out + 1] = "max tokens: " .. tostring(result.max_tokens)
+ end
+ return table.concat(out, "\n")
+end
+
+local function format_search(result)
+ local matches = result.matches or {}
+ if #matches == 0 then
+ return "No models match that query."
+ end
+ local out = { string.format("%d match(es):", #matches) }
+ for _, match in ipairs(matches) do
+ out[#out + 1] = format_match(match)
+ end
+ if result.truncated then
+ out[#out + 1] = string.format("%d shown, more exist — refine the query", #matches)
+ end
+ return table.concat(out, "\n")
+end
+
+local function optional_string(value, field)
+ if value == nil then
+ return nil, nil
+ end
+ if type(value) ~= "string" or value == "" then
+ return nil, string.format("Error: `%s` must be a non-empty string when given.", field)
+ end
+ return value, nil
+end
+
+local function clamp_limit(value)
+ local limit = tonumber(value) or DEFAULT_LIMIT
+ limit = math.floor(limit)
+ if limit < 1 then
+ return 1
+ end
+ if limit > MAX_LIMIT then
+ return MAX_LIMIT
+ end
+ 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)
+ 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")
+ if err then
+ return err
+ end
+ local provider
+ provider, err = optional_string(input.provider, "provider")
+ if err then
+ return err
+ end
+ local text
+ text, err = optional_string(input.query, "query")
+ if err then
+ 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
+ return "Error: " .. ask_err
+ end
+ return format_exact(result, model)
+ end
+
+ if provider or text then
+ local result, ask_err = ask({ provider = provider, query = text, limit = clamp_limit(input.limit) })
+ if not result then
+ return "Error: " .. ask_err
+ end
+ return format_search(result)
+ end
+
+ local result, ask_err = ask({})
+ if not result then
+ return "Error: " .. ask_err
+ end
+ return format_overview(result)
+end
+
+return M
diff --git a/subagents/paths.lua b/subagents/paths.lua
new file mode 100644
index 0000000..678e241
--- /dev/null
+++ b/subagents/paths.lua
@@ -0,0 +1,169 @@
+-- Filesystem and session-path helpers shared by the subagents extension.
+--
+-- Two jobs live here:
+--
+-- 1. Config-layer discovery. `config_roots("agents")` returns the two
+-- 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.
+--
+-- 2. The child session store. `child_store_dir()` derives
+-- `<session_dir>/subagents/<primary session id>` from the host's
+-- `session_info()`, and `ensure_dir` creates it before a store is opened
+-- there. Nothing here duplicates panto's XDG / PANTO_SESSION_DIR /
+-- cwd-encoding logic; the per-cwd session directory arrives ready-made
+-- from the host.
+--
+-- Edge cases: a missing or unreadable directory yields no files rather than an
+-- error, because both config layers are optional. Directory recursion is
+-- capped (MAX_DEPTH) so a symlink cycle cannot hang discovery. Entries whose
+-- type the platform does not report during scandir are stat'ed individually,
+-- which also means a symlinked directory is followed like a real one. Results
+-- are sorted so discovery order — and therefore shadowing — is deterministic.
+--
+-- `luv` is resolved with pcall: only the filesystem helpers need it, so a
+-- host or test run without luv can still use the rest of the extension and
+-- gets a clear error if it actually walks the filesystem.
+
+local ok_uv, uv = pcall(require, "luv")
+
+local MAX_DEPTH = 16
+
+local M = {}
+
+local function host()
+ return require("panto").ext
+end
+
+local function require_uv()
+ if not ok_uv then
+ error("panto-subagents: the 'luv' module is required for filesystem discovery", 2)
+ end
+ return uv
+end
+
+-- Current working directory, i.e. the project root of this panto session.
+function M.cwd()
+ return require_uv().cwd()
+end
+
+-- Extension-less basename of a path: "/a/b/reviewer.md" -> "reviewer".
+function M.stem(path)
+ local base = path:match("[^/]+$") or path
+ return (base:gsub("%.[^.]+$", ""))
+end
+
+-- Whole-file read. Returns nil, err for a file that cannot be opened.
+function M.read_file(path)
+ local fh, err = io.open(path, "r")
+ if not fh then
+ return nil, err or ("could not open " .. path)
+ end
+ local data = fh:read("a")
+ fh:close()
+ if data == nil then
+ return nil, "could not read " .. path
+ end
+ return data
+end
+
+-- User layer first, project layer second: later roots shadow earlier ones.
+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
+ end
+ local cwd = M.cwd()
+ if cwd ~= nil and cwd ~= "" then
+ roots[#roots + 1] = cwd .. "/.panto/" .. name
+ end
+ return roots
+end
+
+-- Every file at or below `root` whose name ends with `suffix`, sorted.
+function M.walk(root, suffix)
+ local lib = require_uv()
+ local found = {}
+
+ local function visit(dir, depth)
+ if depth > MAX_DEPTH then
+ return
+ end
+ local req = lib.fs_scandir(dir)
+ if not req then
+ return
+ end
+ while true do
+ local name, kind = lib.fs_scandir_next(req)
+ if not name then
+ break
+ end
+ local path = dir .. "/" .. name
+ if kind ~= "directory" and kind ~= "file" then
+ local stat = lib.fs_stat(path)
+ kind = stat and stat.type or kind
+ end
+ if kind == "directory" then
+ visit(path, depth + 1)
+ elseif kind == "file" and name:sub(-#suffix) == suffix then
+ found[#found + 1] = path
+ end
+ end
+ end
+
+ visit(root, 1)
+ table.sort(found)
+ return found
+end
+
+-- Create `path` and every missing parent, tolerating one that already exists.
+-- A store cannot be opened on a directory that is not there yet, and the child
+-- catalog is two levels below a session directory panto may itself have only
+-- just made. Returns nil, err rather than raising: this runs inside a spawn,
+-- where a failure is the child's failure to report.
+function M.ensure_dir(path)
+ if not ok_uv then
+ return nil, "panto-subagents: the 'luv' module is required to create the child store directory"
+ end
+ if type(path) ~= "string" or path == "" then
+ return nil, "no child store directory to create"
+ end
+ local made = path:sub(1, 1) == "/" and "" or "."
+ for segment in path:gmatch("[^/]+") do
+ made = made .. "/" .. segment
+ if uv.fs_stat(made) == nil then
+ local ok, err = uv.fs_mkdir(made, 493) -- 0755
+ -- A racing writer is fine; only a directory that still is not
+ -- there afterwards is a failure.
+ if not ok and uv.fs_stat(made) == nil then
+ return nil, tostring(err)
+ end
+ end
+ end
+ return true
+end
+
+-- The per-primary child catalog: <session_dir>/subagents/<primary id>.
+-- Returns the directory plus the session info it came from, so a caller that
+-- also needs the owning session id does not ask twice. This is pure string
+-- assembly; `ensure_dir` creates it at spawn time.
+function M.child_store_dir()
+ local info = host().session_info()
+ if type(info) ~= "table" or type(info.session_dir) ~= "string" or type(info.session_id) ~= "string" then
+ return nil, "no primary session information available"
+ end
+ return info.session_dir .. "/subagents/" .. info.session_id, info
+end
+
+return M
diff --git a/subagents/profiles.lua b/subagents/profiles.lua
new file mode 100644
index 0000000..4ae9f91
--- /dev/null
+++ b/subagents/profiles.lua
@@ -0,0 +1,122 @@
+-- Discover agent profiles: Markdown files with YAML frontmatter.
+--
+-- Two layers are read, lowest precedence first:
+--
+-- 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.
+--
+-- Recognised frontmatter keys, all optional:
+--
+-- name the profile's identity; defaults to the filename stem
+-- description one line shown to the primary in the subagents.run schema
+-- model full `provider:model` only
+-- reasoning passed through as written; the runtime validates it
+--
+-- Unknown keys are ignored so a profile written for another harness still
+-- loads. A `model` that is not Panto's `provider:model` syntax is dropped with
+-- a warning and the child inherits the primary model — a foreign model
+-- spelling must not cost the user a working prompt. Nothing else here is
+-- fatal either: an unreadable file, broken YAML, or a duplicate name inside
+-- one layer produces a warning and discovery continues. Warnings are returned
+-- rather than logged because an extension has no logging channel; the caller
+-- decides where they surface.
+--
+-- Files are read eagerly. Profiles are small and the whole set is needed to
+-- build the tool description at activation anyway, so lazy bodies would buy
+-- nothing.
+
+local frontmatter = require("subagents.frontmatter")
+local paths = require("subagents.paths")
+
+local MODEL_PATTERN = "^[^%s:]+:[^%s:]+$"
+
+local M = {}
+
+local function trim(s)
+ return (s:gsub("^%s+", ""):gsub("%s+$", ""))
+end
+
+local function string_field(data, key)
+ local value = data and data[key]
+ if type(value) ~= "string" then
+ return nil
+ end
+ value = trim(value)
+ if value == "" then
+ return nil
+ end
+ return value
+end
+
+-- Build one profile from a file. Appends to `warnings` on anything odd.
+local function load_profile(path, layer, warnings)
+ local text, err = paths.read_file(path)
+ if not text then
+ warnings[#warnings + 1] = string.format("profile %s: %s", path, tostring(err))
+ return nil
+ end
+
+ local data, body, warning = frontmatter.parse(text)
+ local name = string_field(data, "name") or paths.stem(path)
+ if warning then
+ warnings[#warnings + 1] = string.format("profile %s: %s", name, warning)
+ end
+
+ local model = string_field(data, "model")
+ if model and not model:match(MODEL_PATTERN) then
+ warnings[#warnings + 1] =
+ string.format("profile %s: ignoring model '%s' (not provider:model)", name, model)
+ model = nil
+ end
+
+ return {
+ name = name,
+ description = string_field(data, "description") or "",
+ model = model,
+ reasoning = string_field(data, "reasoning"),
+ body = body,
+ path = path,
+ layer = layer,
+ }
+end
+
+-- discover(roots) -> { list = {...}, by_name = {...}, warnings = {...} }
+--
+-- `roots` defaults to the two config layers and exists so tests can point
+-- discovery at temporary directories. `list` is sorted by name.
+function M.discover(roots)
+ roots = roots or paths.config_roots("agents")
+
+ local by_name = {}
+ local warnings = {}
+
+ for _, root in ipairs(roots) do
+ local seen = {}
+ for _, path in ipairs(paths.walk(root, ".md")) do
+ local profile = load_profile(path, root, warnings)
+ if profile then
+ if seen[profile.name] then
+ warnings[#warnings + 1] = string.format(
+ "profile %s: %s shadows %s in the same layer",
+ profile.name, path, seen[profile.name])
+ end
+ seen[profile.name] = path
+ by_name[profile.name] = profile
+ end
+ end
+ end
+
+ local list = {}
+ for _, profile in pairs(by_name) do
+ list[#list + 1] = profile
+ end
+ table.sort(list, function(a, b) return a.name < b.name end)
+
+ return { list = list, by_name = by_name, warnings = warnings }
+end
+
+return M
diff --git a/subagents/progress.lua b/subagents/progress.lua
new file mode 100644
index 0000000..274e2a8
--- /dev/null
+++ b/subagents/progress.lua
@@ -0,0 +1,260 @@
+-- Live progress cards for in-flight children.
+--
+-- While the primary model is blocked on one of the subagents.* tools, its
+-- children are streaming. This folds each child's event stream into one small
+-- card and renders the set as the component of the tool-call entry that
+-- started them, so several concurrent children stay visually distinct and the
+-- cards disappear with the entry they belong to. The events are presentation
+-- data only: nothing here ever reaches a conversation.
+--
+-- Linking a card to the right entry. The host fires `tool_call_complete` with
+-- the tool-use id just before dispatching that call, and hands the same id to
+-- the handler as `context.tool_call_id`; `claim` records the board under the
+-- id, `bind` ties the running handler coroutine to it, and `card` finds the
+-- board of whichever coroutine is asking. A workflow callback runs on its
+-- handler's own coroutine, so a card raised deep inside one still lands on the
+-- entry the model can see.
+--
+-- Everything degrades to a no-op card: in print mode, in a test, or under a
+-- host with no component machinery there is no board to attach to, and a child
+-- still runs exactly the same.
+--
+-- Rendering is deliberately small — a four-line ring per card, truncated to the
+-- terminal width — because several children run at once and the panel must not
+-- push the transcript off the screen.
+
+local MAX_CARD_LINES = 4
+local MAX_LINE_BYTES = 1024
+local BODY_INDENT = " "
+local TOOL_PREFIX = "subagents."
+
+local GLYPHS = {
+ running = "◷",
+ completed = "✔",
+ failed = "✖",
+ cancelled = "⊘",
+}
+
+local M = {}
+
+-- Boards keyed by tool-call id, and the board bound to each handler coroutine.
+-- Weak keys on the second so a finished handler's binding disappears with it.
+local boards = {}
+local bound = setmetatable({}, { __mode = "k" })
+
+-- ---------------------------------------------------------------------------
+-- Text handling
+-- ---------------------------------------------------------------------------
+
+-- Control bytes would move the cursor or confuse the renderer's width
+-- accounting, so they become spaces. UTF-8 continuation bytes are >= 0x80 and
+-- pass through untouched.
+local function sanitize(text)
+ return (text:gsub("[%z\1-\31\127]", " "))
+end
+
+local function truncate(text, width)
+ if width == nil or width < 1 then
+ return text
+ end
+ local length = utf8.len(text)
+ if length == nil or length <= width then
+ return text
+ end
+ return text:sub(1, (utf8.offset(text, width + 1) or (#text + 1)) - 1)
+end
+
+-- ---------------------------------------------------------------------------
+-- Cards
+-- ---------------------------------------------------------------------------
+
+local card_mt = {}
+card_mt.__index = card_mt
+
+local function repaint(card)
+ local board = card.board
+ if board and board.handle then
+ pcall(board.handle.invalidate, board.handle)
+ end
+end
+
+local function adopt(card, line)
+ card.lines[#card.lines + 1] = line
+ while #card.lines > MAX_CARD_LINES do
+ table.remove(card.lines, 1)
+ end
+end
+
+-- A discrete, already-complete line: a tool marker, a model label, an error.
+local function marker(card, prefix, text)
+ card.partial = false
+ if text == nil or text == "" then
+ return
+ end
+ adopt(card, prefix .. sanitize(text:sub(1, MAX_LINE_BYTES)))
+end
+
+-- Fold a text delta in, breaking it on newlines. A chunk with no newline leaves
+-- the tail line open so the next delta continues it.
+local function append_text(card, text)
+ local rest = text
+ while rest ~= "" do
+ local newline = rest:find("\n", 1, true)
+ local chunk = newline and rest:sub(1, newline - 1) or rest
+ if card.partial and #card.lines > 0 then
+ local index = #card.lines
+ local grown = card.lines[index] .. sanitize(chunk)
+ card.lines[index] = grown:sub(1, MAX_LINE_BYTES)
+ elseif chunk ~= "" then
+ adopt(card, sanitize(chunk:sub(1, MAX_LINE_BYTES)))
+ end
+ card.partial = newline == nil
+ rest = newline and rest:sub(newline + 1) or ""
+ end
+end
+
+-- One run_async event. `content_delta` carries only an index, so the block type
+-- comes from the `block_start` that opened it — the same flag the v0 pump kept.
+function card_mt:event(event)
+ if type(event) ~= "table" then
+ return
+ end
+ local kind = event.type
+ if kind == "block_start" then
+ self.text_block = event.block_type == "text"
+ elseif kind == "content_delta" then
+ if self.text_block and type(event.delta) == "string" then
+ append_text(self, event.delta)
+ end
+ elseif kind == "tool_details" then
+ marker(self, "⚒ ", event.name)
+ elseif kind == "tool_dispatch_complete" or kind == "message_complete" then
+ self.partial = false
+ else
+ return
+ end
+ repaint(self)
+end
+
+-- The child settled. Only a failure or a cancellation gets a closing line; a
+-- completed child's report is the tool result the model already sees.
+function card_mt:done(status, message)
+ self.status = GLYPHS[status] and status or "failed"
+ if self.status ~= "completed" then
+ marker(self, "", message)
+ else
+ self.partial = false
+ end
+ repaint(self)
+end
+
+local NOOP = setmetatable({ lines = {} }, {
+ __index = {
+ event = function() end,
+ done = function() end,
+ },
+})
+
+-- ---------------------------------------------------------------------------
+-- Boards (one per tool-call entry)
+-- ---------------------------------------------------------------------------
+
+local function render(board, width)
+ local out = {}
+ if #board.cards == 0 or (width or 0) <= 4 then
+ return out
+ end
+ out[#out + 1] = ""
+ for _, card in ipairs(board.cards) do
+ local sid = card.sid ~= "" and (" " .. card.sid) or ""
+ out[#out + 1] = truncate(string.format("%s %s%s — %s",
+ GLYPHS[card.status] or GLYPHS.running, card.label, sid, card.status), width)
+ for _, line in ipairs(card.lines) do
+ out[#out + 1] = BODY_INDENT .. truncate(line, width - #BODY_INDENT)
+ end
+ end
+ return out
+end
+
+local function new_board()
+ local board = { cards = {}, handle = nil }
+ board.component = {
+ render = function(_, width)
+ return render(board, width)
+ end,
+ }
+ return board
+end
+
+-- ---------------------------------------------------------------------------
+-- Host wiring
+-- ---------------------------------------------------------------------------
+
+-- `tool_call_complete` for one of our tools: claim that entry's component so
+-- the cards the handler is about to raise have somewhere to render.
+function M.claim(event)
+ -- The event is host userdata; a host that predates any of these fields
+ -- answers nil, and one that predates the whole object cannot be indexed.
+ local ok, name = pcall(function()
+ return event.tool_name
+ end)
+ if not ok or type(name) ~= "string" or name:sub(1, #TOOL_PREFIX) ~= TOOL_PREFIX then
+ return
+ end
+ if type(event.set_component) ~= "function" then
+ return
+ end
+
+ local key = event.id or name
+ local board = new_board()
+ local attached, handle = pcall(event.set_component, event, board.component)
+ if not attached then
+ return
+ end
+ board.handle = handle
+ boards[key] = board
+end
+
+-- Tie the running handler coroutine to the entry it was dispatched for.
+function M.bind(context)
+ local key = type(context) == "table" and context.tool_call_id or nil
+ if key == nil then
+ return
+ end
+ bound[coroutine.running()] = key
+end
+
+-- card(label, id, detail) -> card
+--
+-- The card of the entry whose handler is running, or an inert one when there is
+-- no such entry (print mode, a plain script, a host without components).
+function M.card(label, id, detail)
+ local key = bound[coroutine.running()]
+ local board = key and boards[key]
+ if board == nil or (board.handle and board.handle.alive and not board.handle:alive()) then
+ return NOOP
+ end
+
+ local card = setmetatable({
+ board = board,
+ label = tostring(label or "subagent"),
+ sid = type(id) == "string" and id:sub(1, 8) or "",
+ status = "running",
+ lines = {},
+ partial = false,
+ text_block = false,
+ }, card_mt)
+ board.cards[#board.cards + 1] = card
+ marker(card, "↳ ", detail)
+ repaint(card)
+ return card
+end
+
+-- End of turn: the entries are gone, and the primary's own tool results are the
+-- durable record from here on.
+function M.reset()
+ boards = {}
+ bound = setmetatable({}, { __mode = "k" })
+end
+
+return M
diff --git a/subagents/run.lua b/subagents/run.lua
new file mode 100644
index 0000000..375af91
--- /dev/null
+++ b/subagents/run.lua
@@ -0,0 +1,80 @@
+-- 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.
+--
+-- The call blocks until the child settles. Parallelism is ordinary tool
+-- batching: several subagents.run calls emitted in one batch run concurrently
+-- under the session-wide bound in subagents/jobs.lua, and one failure does not
+-- disturb its siblings.
+--
+-- Failures are values, not exceptions. Everything the model could plausibly
+-- have caused — a missing prompt, both selectors at once, an unknown profile,
+-- an unresumable id, a child that errored or was cancelled — comes back as
+-- readable text. Validation that fails before a child is allocated has
+-- no id to report, and a new child that dies before its first assistant
+-- message has no durable file, so it reports `resumable: false` rather than
+-- promising a continuation that would not resolve.
+--
+-- The result block is plain `key: value` lines rather than JSON: it is read by
+-- a model, and the field names match the design's result shape (id, agent,
+-- status, resumable, then the output or the error message).
+
+local jobs = require("subagents.jobs")
+local spawn = require("subagents.spawn")
+
+local M = {}
+
+-- A resumed child has no profile in hand; its identity comes back from the
+-- manifest metadata stored on its first profile system message.
+local function manifest_agent(result)
+ local manifest = result.manifest
+ if type(manifest) ~= "table" then
+ return nil
+ end
+ local mine = manifest.subagents
+ if type(mine) ~= "table" or type(mine.agent) ~= "string" then
+ return nil
+ end
+ return mine.agent
+end
+
+-- format_result(result, agent_name) -> the model-visible block.
+function M.format_result(result, agent_name)
+ local body = result.output
+ if body == nil or body == "" then
+ body = result.error or ""
+ end
+ return table.concat({
+ "id: " .. (result.id or "(none)"),
+ "agent: " .. (agent_name or manifest_agent(result) or "?"),
+ "status: " .. (result.status or "unknown"),
+ "resumable: " .. tostring(result.resumable == true),
+ "--- output ---",
+ tostring(body),
+ }, "\n")
+end
+
+function M.handle(input, profiles)
+ local spec, err = spawn.build_spec(input, profiles)
+ if not spec then
+ return "Error: " .. tostring(err)
+ end
+
+ local handle, spawn_err = spawn.spawn(spec)
+ if not handle then
+ return "Error: " .. tostring(spawn_err)
+ end
+
+ local results = jobs.await({ handle }, "all")
+ local result = results and results[1]
+ if type(result) ~= "table" then
+ return "Error: the subagent produced no result."
+ end
+
+ return M.format_result(result, spec.label)
+end
+
+return M
diff --git a/subagents/spawn.lua b/subagents/spawn.lua
new file mode 100644
index 0000000..a83e442
--- /dev/null
+++ b/subagents/spawn.lua
@@ -0,0 +1,523 @@
+-- Turn a delegation request into a child agent, and start its turn.
+--
+-- Every path that starts a child — the subagents.run tool, `ctx:agent` in the
+-- Lua workflow API, and the TOML workflow lowering — goes through here, so the
+-- validation rules and the model/reasoning precedence exist exactly once:
+--
+-- model = call.model or profile.model or (inherited)
+-- reasoning = call.reasoning or profile.reasoning or (inherited)
+--
+-- "Inherited" means the field is absent from the spec, and the primary's live
+-- values from `session_info()` apply. A resumed child reads its own last
+-- effective values from the stored conversation instead of a profile, so a
+-- continuation without overrides keeps running on what it ran on before. The
+-- 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), which is how a resumed child re-identifies itself. The parent
+-- dialogue is never copied, which is why the child-role text tells the child
+-- its final message is the whole of what the delegator sees.
+--
+-- Edge cases: a profile with an empty body contributes no system message and
+-- therefore no manifest, so a resumed child started from a body-less profile
+-- reports no agent name. Resume opens the stored conversation as canonical, so
+-- profile edits never reach an existing child. Errors are returned as plain
+-- lowercase messages without an "Error: " prefix; the tool layer decides how to
+-- present them. Nothing here raises: the binding reports its failures by
+-- raising, and every such call goes through `try` so a caller sees one shape.
+
+local jobs = require("subagents.jobs")
+local paths = require("subagents.paths")
+local profiles_mod = require("subagents.profiles")
+local progress = require("subagents.progress")
+
+-- A child never gets the delegation tools themselves: no recursion.
+local TOOL_PREFIX = "subagents."
+
+local M = {}
+
+M.CHILD_ROLE = table.concat({
+ "You are a subagent working inside another agent's session.",
+ "Complete the task you are given directly and end with a clear,",
+ "self-contained report; your final message is returned to the",
+ "delegating agent verbatim. You cannot ask the user questions.",
+}, " ")
+
+local discovered = nil
+
+local function host()
+ return require("panto").ext
+end
+
+local function binding()
+ return require("panto")
+end
+
+-- The binding reports every failure by raising. Route those through one place
+-- so a host error becomes the `nil, message` shape the callers already handle.
+local function try(fn, ...)
+ local ok, value = pcall(fn, ...)
+ if not ok then
+ return false, tostring(value)
+ end
+ return true, value
+end
+
+local function nonempty(value)
+ if type(value) == "string" and value ~= "" then
+ return value
+ end
+ return nil
+end
+
+-- Discovery is cached: activation discovers once, and callers that omit the
+-- profile set (a workflow calling build_spec with one argument) reuse it.
+function M.profiles(given)
+ if given ~= nil then
+ return given
+ end
+ if discovered == nil then
+ discovered = profiles_mod.discover()
+ end
+ return discovered
+end
+
+-- Comma-joined sorted profile names, for "unknown agent" messages.
+function M.agent_names(profiles)
+ profiles = M.profiles(profiles)
+ local names = {}
+ for name in pairs(profiles.by_name or {}) do
+ names[#names + 1] = name
+ end
+ if #names == 0 then
+ return "(no agent profiles found)"
+ end
+ table.sort(names)
+ return table.concat(names, ", ")
+end
+
+local function optional_string(value, field)
+ if value == nil then
+ return nil, nil
+ end
+ if type(value) ~= "string" or value == "" then
+ return nil, string.format("`%s` must be a non-empty string when given", field)
+ end
+ return value, nil
+end
+
+local function build_output(output)
+ if type(output) ~= "table" then
+ return nil, "`output` must be a table"
+ end
+ if type(output.schema) ~= "table" then
+ return nil, "`output.schema` must be a JSON-Schema table"
+ end
+ return {
+ name = output.name or "emit_result",
+ description = output.description,
+ schema = output.schema,
+ }, nil
+end
+
+-- build_spec(input, profiles) -> spec | nil, err
+--
+-- input = { agent | id, prompt, model?, reasoning?, output? }
+function M.build_spec(input, profiles)
+ if type(input) ~= "table" then
+ return nil, "expected a table of arguments"
+ end
+ if type(input.prompt) ~= "string" or input.prompt:match("^%s*$") then
+ return nil, "`prompt` must be a non-empty string"
+ end
+
+ local agent, err = optional_string(input.agent, "agent")
+ if err then
+ return nil, err
+ end
+ local id
+ id, err = optional_string(input.id, "id")
+ 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"
+ end
+ if not agent and not id then
+ return nil, "pass exactly one of `agent` (start a new child) or `id` (continue one)"
+ end
+
+ local model
+ model, err = optional_string(input.model, "model")
+ if err then
+ return nil, err
+ end
+ local reasoning
+ reasoning, err = optional_string(input.reasoning, "reasoning")
+ if err then
+ return nil, err
+ end
+
+ local output
+ if input.output ~= nil then
+ output, err = build_output(input.output)
+ if err then
+ return nil, err
+ end
+ end
+
+ -- The profile is resolved before the store is opened so an unknown agent
+ -- name reports itself instead of a session-directory failure.
+ local profile
+ if agent then
+ profile = (M.profiles(profiles).by_name or {})[agent]
+ if not profile then
+ return nil, string.format("unknown agent '%s'; known: %s", agent, M.agent_names(profiles))
+ end
+ end
+
+ -- child_store_dir returns the session info alongside the directory on
+ -- success, and the failure message in that same slot on failure.
+ local store_dir, info_or_err = paths.child_store_dir()
+ if not store_dir then
+ return nil, tostring(info_or_err)
+ end
+
+ local spec = {
+ store_dir = store_dir,
+ prompt = input.prompt,
+ model = model,
+ reasoning = reasoning,
+ output = output,
+ }
+
+ if id then
+ spec.session_id = id
+ return spec
+ end
+
+ spec.label = profile.name
+ spec.model = model or profile.model
+ spec.reasoning = reasoning or profile.reasoning
+
+ local system_messages = { { text = M.CHILD_ROLE } }
+ if profile.body and profile.body:match("%S") then
+ system_messages[#system_messages + 1] = {
+ text = profile.body,
+ metadata = { subagents = { owner = info_or_err.session_id, agent = profile.name } },
+ }
+ end
+ spec.system_messages = system_messages
+
+ return spec
+end
+
+-- The stored conversation is the only record a resumed child has of itself: the
+-- first system message carrying metadata holds the manifest, and the last user
+-- message whose metadata names this extension holds the model and reasoning its
+-- previous turn resolved to. A message whose metadata is malformed is skipped,
+-- not treated as an error.
+local function read_stored(conv)
+ local ok, messages = try(conv.messages, conv)
+ if not ok or type(messages) ~= "table" then
+ return {}, nil
+ end
+
+ local manifest
+ for index = 1, #messages do
+ if messages[index].role == "system" then
+ local metadata = conv:message_metadata(index)
+ if type(metadata) == "table" then
+ manifest = metadata
+ break
+ end
+ end
+ end
+
+ local defaults = {}
+ for index = #messages, 1, -1 do
+ if messages[index].role == "user" then
+ local metadata = conv:message_metadata(index)
+ local mine = type(metadata) == "table" and metadata.subagents or nil
+ if type(mine) == "table" then
+ defaults.model = nonempty(mine.model)
+ defaults.reasoning = nonempty(mine.reasoning)
+ break
+ end
+ end
+ end
+ 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.
+local function inherited_tools()
+ local primary = host().agent
+ if primary == nil then
+ return {}
+ end
+ local ok, decls = try(primary.tools, primary)
+ if not ok or type(decls) ~= "table" then
+ return {}
+ end
+ local kept = {}
+ for _, decl in ipairs(decls) do
+ if type(decl.name) ~= "string" or decl.name:sub(1, #TOOL_PREFIX) ~= TOOL_PREFIX then
+ kept[#kept + 1] = decl
+ end
+ end
+ return kept
+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 })
+ else
+ conv:add_system_message(message.text)
+ end
+ end
+ return true
+end
+
+-- spawn(spec) -> handle | nil, err
+--
+-- Resolve the model, open the child's store, build the agent, seed or reopen
+-- its conversation, hand it the primary's tools, and start one turn under the
+-- session-wide bound. Everything that can fail before the turn starts (an
+-- unknown id, an id already busy, an unknown model, a store that cannot be
+-- opened) fails here, so a caller either has a running child or a message.
+function M.spawn(spec)
+ if type(spec) ~= "table" then
+ return nil, "expected a spawn spec"
+ end
+ local one_shot = type(spec.output) == "table"
+ if one_shot and spec.session_id then
+ return nil, "a structured-output child cannot be resumed"
+ end
+ if spec.session_id and jobs.active(spec.session_id) then
+ return nil, string.format("subagent '%s' already has a turn in flight", spec.session_id)
+ end
+
+ local ext = host()
+ local panto = binding()
+ local _, info = try(ext.session_info)
+ info = type(info) == "table" and info or {}
+
+ -- A structured worker is ephemeral by contract: no durable file to resume,
+ -- so it never touches the child catalog.
+ local opened, store
+ if one_shot then
+ opened, store = try(panto.null_store)
+ else
+ local made, dir_err = paths.ensure_dir(spec.store_dir)
+ if not made then
+ return nil, dir_err
+ end
+ opened, store = try(panto.file_system_jsonl_store, { dir = spec.store_dir })
+ end
+ if not opened then
+ return nil, tostring(store)
+ end
+ if store == nil then
+ return nil, "the child session store could not be opened"
+ end
+
+ local conv, defaults, manifest
+ if spec.session_id then
+ -- The ownership boundary is the primary's own catalog directory: an id
+ -- from another session simply is not in this store.
+ local unknown = string.format("unknown subagent id '%s' for this session", spec.session_id)
+ local asked, found = try(store.resolve, store, spec.session_id)
+ if not asked then
+ return nil, tostring(found)
+ end
+ if found == nil then
+ return nil, unknown
+ end
+ local loaded
+ loaded, conv = try(store.load, store, spec.session_id)
+ if not loaded then
+ return nil, tostring(conv)
+ end
+ if conv == nil then
+ return nil, unknown
+ end
+ defaults, manifest = read_stored(conv)
+ end
+ defaults = defaults or {}
+
+ local model = spec.model or defaults.model or nonempty(info.model)
+ local reasoning = spec.reasoning or defaults.reasoning or nonempty(info.reasoning)
+
+ local asked, cfg, resolve_err = pcall(ext.resolve_model, {
+ model = model,
+ reasoning = reasoning,
+ tool_choice = one_shot and { name = spec.output.name } or nil,
+ })
+ if not asked then
+ return nil, tostring(cfg)
+ end
+ if cfg == nil then
+ return nil, resolve_err and tostring(resolve_err) or "the child model could not be resolved"
+ end
+ -- The labels the host actually resolved to are what the turn records, so a
+ -- continuation reads back the same spelling.
+ local model_label = nonempty(cfg.model) or model
+ local reasoning_label = nonempty(cfg.reasoning) or reasoning
+
+ local built, agent = try(panto.agent, {
+ config = cfg,
+ store = store,
+ session_id = spec.session_id,
+ conversation = conv,
+ })
+ if not built or agent == nil then
+ return nil, built and "the subagent could not be created" or tostring(agent)
+ end
+
+ if not spec.session_id then
+ local seeded, seed_err = try(seed_conversation, agent, spec)
+ if not seeded then
+ return nil, seed_err
+ end
+ end
+
+ local decls = inherited_tools()
+ if one_shot then
+ decls = { {
+ name = spec.output.name,
+ description = spec.output.description or "",
+ schema = spec.output.schema,
+ } }
+ end
+ local armed, tools_err = try(agent.set_tools, agent, decls)
+ if not armed then
+ return nil, tools_err
+ end
+
+ -- A one-shot worker reports no id: it has no durable session to name.
+ local named, session_id = try(agent.session_id, agent)
+ local id = (not one_shot) and named and nonempty(session_id) or nil
+
+ local card = progress.card(spec.label or "subagent", id, model_label)
+
+ -- The settled run_async result becomes the result table every caller
+ -- already reads: run.lua's block and the workflow API's shape_result.
+ local function shape(raw)
+ raw = type(raw) == "table" and raw or {}
+ local result = {
+ id = id,
+ status = raw.status or "failed",
+ output = raw.text,
+ error = raw.error,
+ resumable = false,
+ model = model_label,
+ reasoning = reasoning_label,
+ manifest = manifest,
+ }
+ if one_shot then
+ local wanted = spec.output.name
+ for _, call in ipairs(raw.tool_calls or {}) do
+ if call.name == wanted then
+ result.structured_json = call.input
+ break
+ end
+ end
+ if result.structured_json == nil and result.status == "completed" then
+ result.status = "failed"
+ result.output = nil
+ result.error = string.format("the child did not call the required '%s' output tool", wanted)
+ end
+ elseif id then
+ -- Durable resumability is a fact about the store, not about the
+ -- status: a turn that died before its first assistant message left
+ -- nothing to continue from. Ask again now that it has settled.
+ local ok, found = try(store.resolve, store, id)
+ result.resumable = ok and found ~= nil
+ end
+ card:done(result.status, result.error)
+ return result
+ end
+
+ local handle, start_err = jobs.start {
+ label = spec.label,
+ id = id,
+ one_shot = one_shot,
+ build = function(wake_fd)
+ local job, err = agent:run_async {
+ prompt = spec.prompt,
+ metadata = { subagents = { model = model_label, reasoning = reasoning_label } },
+ dispatch_tools = not one_shot,
+ wake_fd = wake_fd,
+ }
+ if not job then
+ return nil, err or "the host could not start the subagent"
+ end
+ return job
+ end,
+ on_event = function(event)
+ card:event(event)
+ end,
+ shape = shape,
+ }
+ if not handle then
+ card:done("failed", start_err)
+ return nil, start_err
+ end
+
+ -- The job borrows the agent, which borrows the store; anchor both on the
+ -- handle so neither is collected while the pump is running.
+ handle.agent = agent
+ handle.store = store
+ return handle
+end
+
+return M
diff --git a/subagents/toml_workflows.lua b/subagents/toml_workflows.lua
new file mode 100644
index 0000000..fba1930
--- /dev/null
+++ b/subagents/toml_workflows.lua
@@ -0,0 +1,570 @@
+-- subagents/toml_workflows.lua
+--
+-- Persistent TOML workflows: discovery, validation, execution, the generated
+-- `/workflow:<name>` slash commands, and the model-facing `subagents.workflow`
+-- tool. This is the fixed-DAG surface. Output-dependent branching and dynamic
+-- 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).
+--
+-- 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.
+--
+-- 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.
+--
+-- 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.
+
+local workflow = require("subagents.workflow")
+local paths = require("subagents.paths")
+
+local M = {}
+
+-- toml2lua installs its module under the name "toml", not "toml2lua".
+local TOML_MODULE = "toml"
+
+local function host()
+ return require("panto").ext
+end
+
+local function load_toml()
+ local ok, toml = pcall(require, TOML_MODULE)
+ if not ok or type(toml) ~= "table" or type(toml.parse) ~= "function" then
+ return nil, "the 'toml2lua' rock is required to read TOML workflows"
+ end
+ return toml
+end
+
+-- ---------------------------------------------------------------------------
+-- Parsing and validation
+-- ---------------------------------------------------------------------------
+
+local function is_array(value)
+ if type(value) ~= "table" then
+ return false
+ end
+ local count = 0
+ for key in pairs(value) do
+ if type(key) ~= "number" then
+ return false
+ end
+ count = count + 1
+ end
+ return count == #value
+end
+
+local function optional_string(value, label)
+ if value == nil then
+ return nil, nil
+ end
+ if type(value) ~= "string" or value == "" then
+ return nil, label .. " must be a non-empty string when given"
+ end
+ return value, nil
+end
+
+-- validate(def, fallback_name) -> normalized definition | nil, err
+--
+-- 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 } }.
+function M.validate(def, fallback_name)
+ if type(def) ~= "table" then
+ return nil, "workflow definition must be a table"
+ end
+
+ local name, err = optional_string(def.name, "`name`")
+ if err then
+ return nil, err
+ end
+ name = name or fallback_name
+ if name == nil or name == "" then
+ return nil, "workflow has no name"
+ end
+
+ local description
+ description, err = optional_string(def.description, "`description`")
+ if err then
+ return nil, err
+ end
+
+ if not is_array(def.steps) or #def.steps == 0 then
+ return nil, "workflow '" .. name .. "' has no `steps` array"
+ end
+
+ local steps, by_id = {}, {}
+ for index, raw in ipairs(def.steps) do
+ if type(raw) ~= "table" then
+ return nil, string.format("workflow '%s': step %d is not a table", name, index)
+ end
+ local where = string.format("workflow '%s' step %d", name, index)
+ if type(raw.id) ~= "string" or raw.id == "" then
+ return nil, where .. ": `id` is required and must be a non-empty string"
+ end
+ if by_id[raw.id] then
+ return nil, string.format("workflow '%s': duplicate step id '%s'", name, raw.id)
+ end
+ if type(raw.agent) ~= "string" or raw.agent == "" then
+ return nil, string.format("workflow '%s' step '%s': `agent` is required", name, raw.id)
+ end
+ if type(raw.prompt) ~= "string" or raw.prompt == "" then
+ return nil, string.format("workflow '%s' step '%s': `prompt` is required", name, raw.id)
+ end
+
+ local model, model_err = optional_string(raw.model, "`model`")
+ if model_err then
+ return nil, string.format("workflow '%s' step '%s': %s", name, raw.id, model_err)
+ end
+ local reasoning, reasoning_err = optional_string(raw.reasoning, "`reasoning`")
+ if reasoning_err then
+ return nil, string.format("workflow '%s' step '%s': %s", name, raw.id, reasoning_err)
+ end
+
+ local needs = {}
+ if raw.needs ~= nil then
+ if not is_array(raw.needs) then
+ return nil, string.format("workflow '%s' step '%s': `needs` must be an array", name, raw.id)
+ end
+ for _, need in ipairs(raw.needs) do
+ if type(need) ~= "string" or need == "" then
+ return nil, string.format("workflow '%s' step '%s': `needs` entries must be step ids", name, raw.id)
+ end
+ needs[#needs + 1] = need
+ end
+ end
+
+ local step = {
+ id = raw.id,
+ agent = raw.agent,
+ prompt = raw.prompt,
+ model = model,
+ reasoning = reasoning,
+ needs = needs,
+ }
+ steps[#steps + 1] = step
+ by_id[raw.id] = step
+ end
+
+ -- Dependencies must exist before the cycle walk, so an unknown name reports
+ -- itself rather than looking like a broken graph.
+ local terminal = {}
+ for _, step in ipairs(steps) do
+ terminal[step.id] = true
+ end
+ for _, step in ipairs(steps) do
+ for _, need in ipairs(step.needs) do
+ if not by_id[need] then
+ return nil, string.format("workflow '%s' step '%s': unknown dependency '%s'", name, step.id, need)
+ end
+ terminal[need] = nil
+ end
+ end
+
+ -- Iterative-free DFS with a per-node mark: "open" means the node is on the
+ -- current path, so meeting it again is a cycle.
+ local mark = {}
+ local function visit(step, trail)
+ if mark[step.id] == "done" then
+ return true
+ end
+ if mark[step.id] == "open" then
+ return false, string.format(
+ "workflow '%s': dependency cycle through '%s' (%s)",
+ name, step.id, table.concat(trail, " -> ") .. " -> " .. step.id)
+ end
+ mark[step.id] = "open"
+ trail[#trail + 1] = step.id
+ for _, need in ipairs(step.needs) do
+ local ok, cycle_err = visit(by_id[need], trail)
+ if not ok then
+ return false, cycle_err
+ end
+ end
+ trail[#trail] = nil
+ mark[step.id] = "done"
+ return true
+ end
+ for _, step in ipairs(steps) do
+ local ok, cycle_err = visit(step, {})
+ if not ok then
+ return nil, cycle_err
+ end
+ end
+
+ return {
+ name = name,
+ description = description,
+ steps = steps,
+ by_id = by_id,
+ terminal = terminal,
+ }
+end
+
+-- parse(text, fallback_name) -> definition | nil, err, declared_name
+--
+-- On failure the third value is the `name` the document declared, when it read
+-- as one, so discovery can index a broken file under the name it claims rather
+-- than its filename stem — otherwise a broken project file would fail to shadow
+-- the user workflow of the same name and the error would go unreported.
+function M.parse(text, fallback_name)
+ local toml, err = load_toml()
+ if not toml then
+ return nil, err
+ end
+ local ok, parsed = pcall(toml.parse, text, { strict = true })
+ if not ok then
+ return nil, "invalid TOML: " .. tostring(parsed)
+ end
+ if type(parsed) ~= "table" then
+ return nil, "invalid TOML: the document did not parse into a table"
+ end
+ local def, validate_err = M.validate(parsed, fallback_name)
+ if def then
+ return def
+ end
+ local declared = parsed.name
+ if type(declared) ~= "string" or declared == "" then
+ declared = nil
+ end
+ return nil, validate_err, declared
+end
+
+-- ---------------------------------------------------------------------------
+-- Discovery
+-- ---------------------------------------------------------------------------
+
+-- discover() -> { list = ordered array, by_name = map, warnings = array }
+--
+-- Later roots (the project layer) 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
+-- the name it declares (falling back to its stem only when it declares none), so
+-- a broken project workflow reports its error rather than silently letting the
+-- same-named user workflow run in its place.
+function M.discover()
+ local list, by_name, warnings = {}, {}, {}
+
+ -- Walking is the only part that can raise (a missing luv, a hostile
+ -- filesystem); a root that cannot be read contributes a warning and no
+ -- workflows, so discovery as a whole keeps its "never throws" contract.
+ local roots_ok, roots = pcall(paths.config_roots, "workflows")
+ if not roots_ok then
+ return { list = list, by_name = by_name, warnings = { tostring(roots) } }
+ end
+
+ for _, root in ipairs(roots) do
+ local walk_ok, found = pcall(paths.walk, root, ".toml")
+ if not walk_ok then
+ warnings[#warnings + 1] = root .. ": " .. tostring(found)
+ found = {}
+ end
+ for _, path in ipairs(found) do
+ local stem = paths.stem(path)
+ local text, read_err = paths.read_file(path)
+ local entry
+ if not text then
+ entry = { name = stem, path = path, error = tostring(read_err) }
+ else
+ local def, err, declared = M.parse(text, stem)
+ if def then
+ entry = { name = def.name, path = path, definition = def }
+ else
+ entry = { name = declared or stem, path = path, error = tostring(err) }
+ end
+ end
+ if entry.error then
+ warnings[#warnings + 1] = path .. ": " .. entry.error
+ end
+
+ local existing = by_name[entry.name]
+ if existing then
+ for index, candidate in ipairs(list) do
+ if candidate == existing then
+ list[index] = entry
+ break
+ end
+ end
+ else
+ list[#list + 1] = entry
+ end
+ by_name[entry.name] = entry
+ end
+ end
+
+ return { list = list, by_name = by_name, warnings = warnings }
+end
+
+-- ---------------------------------------------------------------------------
+-- Lowering onto the Lua workflow API
+-- ---------------------------------------------------------------------------
+
+local function dependency_text(result)
+ if result == nil then
+ return "[failed: not run]"
+ end
+ if result.status == "completed" then
+ return workflow.output_text(result)
+ end
+ 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.
+local function step_prompt(step, input, settled)
+ local parts = { step.prompt, "\n\n## Workflow input\n\n", input }
+ for _, need in ipairs(step.needs) do
+ parts[#parts + 1] = "\n\n## Output of " .. need .. "\n\n"
+ parts[#parts + 1] = dependency_text(settled[need])
+ end
+ return table.concat(parts)
+end
+
+M.step_prompt = step_prompt
+
+-- lower(def) -> workflow object
+function M.lower(def)
+ return workflow.workflow(function(ctx, input)
+ input = input or ""
+ local waiting = {}
+ for index, step in ipairs(def.steps) do
+ waiting[index] = step
+ end
+
+ local settled = {}
+ local live, live_step = {}, {}
+
+ -- One pass may unblock another (a skip cascades to its dependents), so
+ -- this repeats until nothing more can start or be skipped.
+ local function advance()
+ local changed = true
+ while changed do
+ changed = false
+ local index = 1
+ while index <= #waiting do
+ local step = waiting[index]
+ local ready, skip = true, false
+ for _, need in ipairs(step.needs) do
+ local result = settled[need]
+ if result == nil then
+ ready = false
+ elseif result.status ~= "completed" then
+ skip = true
+ break
+ end
+ end
+
+ if skip then
+ table.remove(waiting, index)
+ settled[step.id] = {
+ status = "skipped",
+ error = "skipped: a dependency did not complete",
+ }
+ changed = true
+ elseif ready then
+ table.remove(waiting, index)
+ local handle = ctx:agent({
+ agent = step.agent,
+ prompt = step_prompt(step, input, settled),
+ model = step.model,
+ reasoning = step.reasoning,
+ })
+ live[#live + 1] = handle
+ live_step[handle] = step.id
+ changed = true
+ else
+ index = index + 1
+ end
+ end
+ end
+ end
+
+ advance()
+ while #live > 0 do
+ local result, remaining = ctx:await(live, "first")
+ if result == nil then
+ break
+ end
+ local still = {}
+ for _, handle in ipairs(remaining or {}) do
+ still[handle] = true
+ end
+ for _, handle in ipairs(live) do
+ if not still[handle] then
+ settled[live_step[handle]] = result
+ break
+ end
+ end
+ live = remaining or {}
+ advance()
+ 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
+ end
+ return out
+ end)
+end
+
+-- run(def, input, profiles) -> array of terminal results
+function M.run(def, input, profiles)
+ return workflow.execute(M.lower(def), input or "", { profiles = profiles })
+end
+
+-- ---------------------------------------------------------------------------
+-- Model- and user-visible formatting
+-- ---------------------------------------------------------------------------
+
+local function format_step(result)
+ return table.concat({
+ "step: " .. tostring(result.id),
+ "status: " .. tostring(result.status),
+ "--- output ---",
+ workflow.output_text(result),
+ }, "\n")
+end
+
+function M.format_results(results)
+ if type(results) ~= "table" or #results == 0 then
+ return "The workflow produced no terminal results."
+ end
+ local blocks = {}
+ for index, result in ipairs(results) do
+ blocks[index] = format_step(result)
+ end
+ return table.concat(blocks, "\n\n")
+end
+
+-- ---------------------------------------------------------------------------
+-- Tool and command entry points
+-- ---------------------------------------------------------------------------
+
+local registry = nil
+
+-- The discovered set, discovered once per activation.
+function M.workflows()
+ if registry == nil then
+ registry = M.discover()
+ end
+ return registry
+end
+
+local function known_names(found)
+ local names = {}
+ for name in pairs(found.by_name) do
+ names[#names + 1] = name
+ end
+ if #names == 0 then
+ return "(no workflows found)"
+ end
+ table.sort(names)
+ return table.concat(names, ", ")
+end
+
+local function run_named(name, input, profiles)
+ local found = M.workflows()
+ local entry = found.by_name[name]
+ if not entry then
+ return "Error: unknown workflow '" .. tostring(name) .. "'; known: " .. known_names(found)
+ end
+ if not entry.definition then
+ return "Error: workflow '" .. name .. "' failed to load: " .. tostring(entry.error)
+ end
+ local ok, results = pcall(M.run, entry.definition, input, profiles)
+ if not ok then
+ return "Error: " .. tostring(results)
+ end
+ 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.
+function M.handle(input, profiles)
+ 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
+
+ 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)
+ end
+
+ local def, err = M.validate({ name = "transient", steps = input.steps }, "transient")
+ if not def then
+ return "Error: " .. tostring(err)
+ end
+ local ok, results = pcall(M.run, def, input.prompt, profiles)
+ if not ok then
+ return "Error: " .. tostring(results)
+ end
+ return M.format_results(results)
+end
+
+-- Discover the workflows and register a `/workflow:<name>` command for each
+-- valid one. Invalid files register nothing; their errors stay in the
+-- discovery warnings and surface through `subagents.workflow`.
+function M.discover_and_register(profiles)
+ registry = M.discover()
+ local ext = host()
+ for _, entry in ipairs(registry.list) do
+ if entry.definition then
+ local def = entry.definition
+ ext.register_command({
+ name = "workflow:" .. def.name,
+ description = def.description or ("Run the " .. def.name .. " workflow."),
+ handler = function(args)
+ local ok, results = pcall(M.run, def, args or "", profiles)
+ if not ok then
+ return "[workflow error: " .. tostring(results) .. "]"
+ end
+ return M.format_results(results)
+ end,
+ })
+ end
+ end
+ return registry
+end
+
+return M
diff --git a/subagents/workflow.lua b/subagents/workflow.lua
new file mode 100644
index 0000000..b1d3e46
--- /dev/null
+++ b/subagents/workflow.lua
@@ -0,0 +1,614 @@
+-- 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.
+--
+-- ctx surface:
+-- ctx:agent{ agent=, 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
+--
+-- Profile resolution and spawn-spec construction are NOT duplicated here: both
+-- come from subagents/spawn.lua (`build_spec(input, profiles)` / `spawn(spec)`)
+-- so the tool > profile > primary precedence lives in exactly one place. The
+-- only thing this file adds to the spec is the synthetic structured-output
+-- tool, which it normalizes to { name, description, schema } (defaulting the
+-- name to "emit_result") so the host seam always sees the same shape.
+--
+-- Edge cases and deliberate policies:
+--
+-- * Child failures are values, never errors. A rejected spawn produces a
+-- 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.
+-- * "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
+-- settled result is ever lost between awaits.
+-- * Handles the callback never awaited are awaited ("all") after it returns,
+-- purely so no child is orphaned; those results are discarded.
+-- * Structured output is decoded from result.structured_json and validated
+-- against output.schema. Validation prefers the `jsonschema` rock and falls
+-- back to the small built-in subset validator below when it is absent --
+-- that rock pulls in lrexlib-pcre, which needs a system PCRE and fails to
+-- build on stock macOS, and a failed rock install would otherwise take the
+-- whole extension down silently. A validation failure turns the result into
+-- status "failed"; it is never reported as a successful structured result.
+-- * The host seam is reached through `require("panto").ext` at call time, not
+-- aliased at load time, matching subagents/spawn.lua so a test can install a
+-- fake `panto` module before the first call rather than before the require.
+-- subagents.jobs is required the same way, so load order between the two
+-- never matters.
+
+local spawn = require("subagents.spawn")
+
+local M = {}
+
+local workflow_mt = { __name = "subagents.workflow" }
+
+M.workflow_mt = workflow_mt
+
+-- ---------------------------------------------------------------------------
+-- Host seam access
+-- ---------------------------------------------------------------------------
+
+local function host()
+ return require("panto").ext
+end
+
+-- The job machinery, resolved at call time for the same reason as the host seam.
+local function jobs()
+ return require("subagents.jobs")
+end
+
+local function host_json()
+ local ok, ext = pcall(host)
+ if ok and type(ext) == "table" and type(ext.json) == "table" then
+ return ext.json
+ end
+ return nil
+end
+
+-- Decode a JSON document. Panto installs its own codec as `panto.ext.json`;
+-- the dkjson fallback only matters for a bare `lua` process running the specs.
+local function json_decode(text)
+ local json = host_json()
+ if json and json.decode then
+ return json.decode(text)
+ end
+ local ok, dkjson = pcall(require, "dkjson")
+ if ok and type(dkjson) == "table" and dkjson.decode then
+ local value, _, err = dkjson.decode(text)
+ if err then
+ error(err, 0)
+ end
+ return value
+ end
+ error("no JSON decoder available (panto.ext.json missing, dkjson not installed)", 0)
+end
+
+local function json_encode(value)
+ local json = host_json()
+ if json and json.encode then
+ local ok, encoded = pcall(json.encode, value)
+ if ok then
+ return encoded
+ end
+ end
+ local ok, dkjson = pcall(require, "dkjson")
+ if ok and type(dkjson) == "table" and dkjson.encode then
+ local encoded_ok, encoded = pcall(dkjson.encode, value)
+ if encoded_ok then
+ return encoded
+ end
+ end
+ return tostring(value)
+end
+
+M.json_decode = json_decode
+M.json_encode = json_encode
+
+-- ---------------------------------------------------------------------------
+-- Schema validation
+-- ---------------------------------------------------------------------------
+
+-- Built-in fallback validator: the JSON Schema subset that structured child
+-- output actually uses. Anything it does not understand is ignored rather than
+-- rejected, so an unrecognized keyword never fails a legitimate result.
+local function is_array_like(value)
+ local count = 0
+ for key in pairs(value) do
+ if type(key) ~= "number" then
+ return false
+ end
+ count = count + 1
+ end
+ return count == #value
+end
+
+local function type_matches(value, expected)
+ if expected == "object" then
+ return type(value) == "table"
+ elseif expected == "array" then
+ return type(value) == "table" and is_array_like(value)
+ elseif expected == "string" then
+ return type(value) == "string"
+ elseif expected == "number" then
+ return type(value) == "number"
+ elseif expected == "integer" then
+ return type(value) == "number" and value == math.floor(value)
+ elseif expected == "boolean" then
+ return type(value) == "boolean"
+ elseif expected == "null" then
+ return value == nil or type(value) == "userdata"
+ end
+ return true
+end
+
+local function check_schema(value, schema, path)
+ if type(schema) ~= "table" then
+ return true
+ end
+
+ local expected = schema.type
+ if type(expected) == "string" then
+ if not type_matches(value, expected) then
+ return false, string.format("%s: expected %s, got %s", path, expected, type(value))
+ end
+ elseif type(expected) == "table" then
+ local any = false
+ for _, candidate in ipairs(expected) do
+ if type_matches(value, candidate) then
+ any = true
+ break
+ end
+ end
+ if not any then
+ return false, string.format("%s: no listed type matched %s", path, type(value))
+ end
+ end
+
+ if type(schema.enum) == "table" then
+ local found = false
+ for _, allowed in ipairs(schema.enum) do
+ if allowed == value then
+ found = true
+ break
+ end
+ end
+ if not found then
+ return false, string.format("%s: value is not one of the enumerated options", path)
+ end
+ end
+
+ if type(value) == "string" then
+ if type(schema.minLength) == "number" and #value < schema.minLength then
+ return false, string.format("%s: shorter than minLength %d", path, schema.minLength)
+ end
+ if type(schema.maxLength) == "number" and #value > schema.maxLength then
+ return false, string.format("%s: longer than maxLength %d", path, schema.maxLength)
+ end
+ end
+
+ if type(value) == "number" then
+ if type(schema.minimum) == "number" and value < schema.minimum then
+ return false, string.format("%s: below minimum %s", path, tostring(schema.minimum))
+ end
+ if type(schema.maximum) == "number" and value > schema.maximum then
+ return false, string.format("%s: above maximum %s", path, tostring(schema.maximum))
+ end
+ end
+
+ if type(value) ~= "table" then
+ return true
+ end
+
+ if type(schema.required) == "table" then
+ for _, key in ipairs(schema.required) do
+ if value[key] == nil then
+ return false, string.format("%s: missing required property '%s'", path, tostring(key))
+ end
+ end
+ end
+
+ if type(schema.properties) == "table" then
+ for key, sub in pairs(schema.properties) do
+ if value[key] ~= nil then
+ local ok, err = check_schema(value[key], sub, path .. "." .. tostring(key))
+ if not ok then
+ return false, err
+ end
+ end
+ end
+ if schema.additionalProperties == false then
+ for key in pairs(value) do
+ if schema.properties[key] == nil then
+ return false, string.format("%s: unexpected property '%s'", path, tostring(key))
+ end
+ end
+ end
+ end
+
+ if type(schema.items) == "table" then
+ if type(schema.minItems) == "number" and #value < schema.minItems then
+ return false, string.format("%s: fewer than minItems %d", path, schema.minItems)
+ end
+ if type(schema.maxItems) == "number" and #value > schema.maxItems then
+ return false, string.format("%s: more than maxItems %d", path, schema.maxItems)
+ end
+ for index, item in ipairs(value) do
+ local ok, err = check_schema(item, schema.items, string.format("%s[%d]", path, index))
+ if not ok then
+ return false, err
+ end
+ end
+ end
+
+ return true
+end
+
+local function validator_for(schema)
+ local ok, jsonschema = pcall(require, "jsonschema")
+ if ok and type(jsonschema) == "table" and jsonschema.generate_validator then
+ local generated_ok, generated = pcall(jsonschema.generate_validator, schema)
+ if generated_ok and type(generated) == "function" then
+ return generated
+ end
+ end
+ return function(value)
+ return check_schema(value, schema, "output")
+ end
+end
+
+M.validate = function(value, schema)
+ return validator_for(schema)(value)
+end
+
+-- ---------------------------------------------------------------------------
+-- Result shaping
+-- ---------------------------------------------------------------------------
+
+local function copy_result(result)
+ local shaped = {}
+ if type(result) == "table" then
+ for key, value in pairs(result) do
+ shaped[key] = value
+ end
+ end
+ if shaped.status == nil then
+ shaped.status = "failed"
+ shaped.error = shaped.error or "the host returned no result for this job"
+ shaped.resumable = false
+ end
+ return shaped
+end
+
+local function fail(shaped, message)
+ shaped.status = "failed"
+ shaped.error = message
+ shaped.output = nil
+ return shaped
+end
+
+-- Turn a host result into the value a workflow callback sees. Only handles
+-- carrying an output schema decode structured JSON; everything else passes
+-- through untouched.
+local function shape_result(result, handle)
+ local shaped = copy_result(result)
+ local schema = handle and handle.output_schema
+ if schema == nil or shaped.status ~= "completed" then
+ return shaped
+ end
+
+ local raw = shaped.structured_json
+ if type(raw) ~= "string" or raw == "" then
+ return fail(shaped, "structured output missing: the child produced no structured result")
+ end
+
+ local decoded_ok, decoded = pcall(json_decode, raw)
+ if not decoded_ok then
+ return fail(shaped, "structured output failed validation: " .. tostring(decoded))
+ end
+
+ local valid, message = validator_for(schema)(decoded)
+ if not valid then
+ return fail(shaped, "structured output failed validation: " .. tostring(message or "schema mismatch"))
+ end
+
+ shaped.output = decoded
+ return shaped
+end
+
+-- The host may hand back a single result table or an array of them; both are
+-- normalized to an array here. A result always carries `status`, which is what
+-- distinguishes the two shapes.
+local function as_result_array(value)
+ if type(value) ~= "table" then
+ return {}
+ end
+ if value.status ~= nil then
+ return { value }
+ end
+ return value
+end
+
+-- ---------------------------------------------------------------------------
+-- Handles and context
+-- ---------------------------------------------------------------------------
+
+local handle_mt = {}
+handle_mt.__index = handle_mt
+handle_mt.__name = "subagents.handle"
+
+function handle_mt:await()
+ return self.ctx:await({ self }, "all")[1]
+end
+
+local ctx_mt = {}
+ctx_mt.__index = ctx_mt
+ctx_mt.__name = "subagents.ctx"
+
+-- Per-run state lives here, not on ctx: the sandboxed guest holds the ctx table
+-- and would otherwise be able to raise its own job cap (`ctx.max_jobs = nil`),
+-- reset the counter, or read the profile set. ctx itself is an empty table
+-- exposing only `agent` and `await`. Weak keys so a finished run is collectable.
+local state = setmetatable({}, { __mode = "k" })
+
+-- 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
+-- outside the job cap. The guest sees only `result` and `await`.
+local job_of = setmetatable({}, { __mode = "k" })
+
+function ctx_mt:agent(input)
+ if type(input) ~= "table" then
+ error("ctx:agent expects a table of { agent =, prompt =, ... }", 2)
+ end
+ local s = state[self]
+ if not s then
+ error("ctx:agent must be called on a workflow context (use ctx:agent{...})", 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
+
+ -- 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
+ error(tostring(spec_err), 2)
+ end
+
+ local output_schema = nil
+ if type(spec.output) == "table" then
+ output_schema = spec.output.schema
+ end
+
+ local handle = setmetatable({
+ ctx = self,
+ spec = spec,
+ output_schema = output_schema,
+ result = nil,
+ }, handle_mt)
+
+ local job, job_err = spawn.spawn(spec)
+ if not job then
+ -- A rejected spawn is a child failure, not a workflow error.
+ handle.result = {
+ id = nil,
+ status = "failed",
+ error = tostring(job_err or "the host refused to start the child"),
+ resumable = false,
+ }
+ else
+ job_of[handle] = job
+ end
+
+ s.job_count = s.job_count + 1
+ s.outstanding[#s.outstanding + 1] = handle
+ return handle
+end
+
+-- Pick the first handle (in input order) that already holds a settled result,
+-- returning it with the remaining handles.
+local function take_settled(handles)
+ for index, handle in ipairs(handles) do
+ if handle.result ~= nil then
+ local remaining = {}
+ for other_index, other in ipairs(handles) do
+ if other_index ~= index then
+ remaining[#remaining + 1] = other
+ end
+ end
+ return handle.result, remaining
+ end
+ end
+ return nil, nil
+end
+
+local function pending_handles(handles)
+ local pending, started = {}, {}
+ for _, handle in ipairs(handles) do
+ if handle.result == nil and job_of[handle] ~= nil then
+ pending[#pending + 1] = handle
+ started[#started + 1] = job_of[handle]
+ end
+ end
+ return pending, started
+end
+
+function ctx_mt:await(handles, mode)
+ mode = mode or "all"
+ if type(handles) ~= "table" then
+ error("ctx:await expects an array of handles", 2)
+ end
+ if getmetatable(handles) == handle_mt then
+ handles = { handles }
+ end
+ if mode ~= "all" and mode ~= "first" then
+ error("ctx:await mode must be \"all\" or \"first\"", 2)
+ end
+
+ if mode == "all" then
+ local pending, started = pending_handles(handles)
+ 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)
+ end
+ end
+ local out = {}
+ for index, handle in ipairs(handles) do
+ out[index] = handle.result or copy_result(nil)
+ end
+ return out
+ end
+
+ local ready, remaining = take_settled(handles)
+ if ready ~= nil then
+ return ready, remaining
+ end
+
+ local pending, started = pending_handles(handles)
+ if #pending == 0 then
+ return nil, {}
+ end
+
+ local results, still_pending = jobs().await(started, "first")
+ results = as_result_array(results)
+
+ -- Everything not listed as still-running has settled; pair those handles
+ -- with the returned results in order. The listed jobs are the very handles
+ -- that went in, so they match by identity.
+ local settled = pending
+ if type(still_pending) == "table" and #still_pending > 0 then
+ local still_running = {}
+ for _, job in ipairs(still_pending) do
+ still_running[job] = true
+ end
+ settled = {}
+ for _, handle in ipairs(pending) do
+ if not still_running[job_of[handle]] then
+ settled[#settled + 1] = handle
+ end
+ end
+ end
+ for index, result in ipairs(results) do
+ local handle = settled[index]
+ if handle then
+ handle.result = shape_result(result, handle)
+ end
+ end
+
+ ready, remaining = take_settled(handles)
+ if ready == nil then
+ -- 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)
+ return take_settled(handles)
+ end
+ return ready, remaining
+end
+
+-- ---------------------------------------------------------------------------
+-- Workflow objects and execution
+-- ---------------------------------------------------------------------------
+
+function M.workflow(fn)
+ if type(fn) ~= "function" then
+ error("subagents.workflow expects a function(ctx, input)", 2)
+ end
+ return setmetatable({ run = fn }, workflow_mt)
+end
+
+function M.is_workflow(value)
+ return type(value) == "table" and getmetatable(value) == workflow_mt
+end
+
+-- Settle any handle the callback left running so a returning workflow never
+-- orphans a child. Results are intentionally discarded.
+local function drain(ctx)
+ local pending = {}
+ for _, handle in ipairs(state[ctx].outstanding) do
+ if handle.result == nil then
+ pending[#pending + 1] = handle
+ end
+ end
+ if #pending == 0 then
+ return
+ end
+ pcall(function()
+ ctx:await(pending, "all")
+ end)
+end
+
+-- Run `wf` against `input`. opts:
+-- max_jobs -- cap on ctx:agent calls (nil = unbounded; the sandbox passes 32)
+-- profiles -- discovered profile set for spawn.build_spec (nil = discover)
+-- 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.
+function M.execute(wf, input, opts)
+ if not M.is_workflow(wf) then
+ error("subagents.workflow.execute expects a workflow object", 2)
+ end
+ opts = opts or {}
+
+ local ctx = setmetatable({}, ctx_mt)
+ state[ctx] = {
+ profiles = opts.profiles,
+ max_jobs = opts.max_jobs,
+ job_count = 0,
+ outstanding = {},
+ }
+
+ local co = coroutine.running()
+ if opts.on_resume then
+ opts.on_resume(co)
+ end
+ -- pcall is yieldable in 5.4, so the callback may still await across it.
+ local packed = table.pack(pcall(wf.run, ctx, input))
+ if opts.on_yield then
+ opts.on_yield(co)
+ end
+
+ drain(ctx)
+ if not packed[1] then
+ error(packed[2], 0)
+ end
+ return table.unpack(packed, 2, packed.n)
+end
+
+-- A child's output text: a structured result decodes to a table, which is
+-- re-encoded compactly so anything model-visible is still a string.
+function M.output_text(result)
+ local output = result.output
+ if type(output) == "table" then
+ return json_encode(output)
+ end
+ if output == nil or output == "" then
+ return tostring(result.error or "")
+ end
+ return tostring(output)
+end
+
+return M