From 4f0a91ef55fe96835172bdad34feec1e2a0a0977 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 16 Aug 2026 20:42:43 -0600 Subject: subagents extension on the generic host surfaces The rock now owns all subagent policy on top of libpanto-lua's generic APIs: children are ordinary panto.agent instances over rock-constructed stores, started with agent:run_async and awaited by arming uv.new_poll on each job's wake_fd from the tool handler's coroutine. subagents/jobs.lua carries the session policy the host used to own: the concurrency gate (4 running, FIFO queue, cancel-while-queued never starts), the await contract (results in input order; "first" returns settled plus remaining by identity), and settle-time shaping. subagents/spawn.lua seeds new children (primary system context, child role, profile body with manifest metadata), resolves model/reasoning through panto.ext.resolve_model, filters subagents.* out of the inherited tool set via agent:set_tools, and reads resume defaults back from stored message metadata. One-shot structured workers are a null_store agent with a declaration-only output tool, tool_choice forced, dispatch_tools=false. subagents/progress.lua renders per-tool-entry cards through the component handle's invalidate seam; turn_interrupt cancels live children, turn_end closes them. Spec suite rewritten against fakes of the new surfaces (98 cases), including gate/queue/cancel bounds, resume-default extraction, one-shot capture via unresolved tool calls, tool filtering, and manifest seeding. --- .gitignore | 2 + README.md | 39 +++ REFACTOR.md | 196 +++++++++++ init.lua | 213 ++++++++++++ mise.toml | 38 +++ panto-subagents-0.1.0-1.rockspec | 84 +++++ spec/fake_ext.lua | 709 +++++++++++++++++++++++++++++++++++++++ spec/run.lua | 81 +++++ spec/test_frontmatter.lua | 88 +++++ spec/test_init.lua | 180 ++++++++++ spec/test_jobs.lua | 244 ++++++++++++++ spec/test_luatool.lua | 188 +++++++++++ spec/test_models.lua | 158 +++++++++ spec/test_profiles.lua | 138 ++++++++ spec/test_run.lua | 332 ++++++++++++++++++ spec/test_toml_workflows.lua | 404 ++++++++++++++++++++++ spec/test_workflow.lua | 299 +++++++++++++++++ subagents/frontmatter.lua | 96 ++++++ subagents/jobs.lua | 427 +++++++++++++++++++++++ subagents/luatool.lua | 193 +++++++++++ subagents/models.lua | 233 +++++++++++++ subagents/paths.lua | 169 ++++++++++ subagents/profiles.lua | 122 +++++++ subagents/progress.lua | 260 ++++++++++++++ subagents/run.lua | 80 +++++ subagents/spawn.lua | 523 +++++++++++++++++++++++++++++ subagents/toml_workflows.lua | 570 +++++++++++++++++++++++++++++++ subagents/workflow.lua | 614 +++++++++++++++++++++++++++++++++ 28 files changed, 6680 insertions(+) create mode 100644 .gitignore create mode 100644 REFACTOR.md create mode 100644 init.lua create mode 100644 mise.toml create mode 100644 panto-subagents-0.1.0-1.rockspec create mode 100644 spec/fake_ext.lua create mode 100644 spec/run.lua create mode 100644 spec/test_frontmatter.lua create mode 100644 spec/test_init.lua create mode 100644 spec/test_jobs.lua create mode 100644 spec/test_luatool.lua create mode 100644 spec/test_models.lua create mode 100644 spec/test_profiles.lua create mode 100644 spec/test_run.lua create mode 100644 spec/test_toml_workflows.lua create mode 100644 spec/test_workflow.lua create mode 100644 subagents/frontmatter.lua create mode 100644 subagents/jobs.lua create mode 100644 subagents/luatool.lua create mode 100644 subagents/models.lua create mode 100644 subagents/paths.lua create mode 100644 subagents/profiles.lua create mode 100644 subagents/progress.lua create mode 100644 subagents/run.lua create mode 100644 subagents/spawn.lua create mode 100644 subagents/toml_workflows.lua create mode 100644 subagents/workflow.lua 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 `/subagents/` | 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:` 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.` 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: 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/` and +-- `/.panto/`. 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 `**/*.` beneath a root. +-- +-- 2. The child session store. `child_store_dir()` derives +-- `/subagents/` 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: /subagents/. +-- 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. /.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:` 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 `/.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:` 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 -- cgit v1.3