summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--DESIGN.md178
-rw-r--r--panto-subagents-0.1.0-1.rockspec13
-rw-r--r--spec/fake_ext.lua35
-rw-r--r--spec/test_jobs.lua64
-rw-r--r--spec/test_run.lua24
-rw-r--r--spec/test_workflow.lua47
-rw-r--r--subagents/jobs.lua69
-rw-r--r--subagents/luatool.lua3
-rw-r--r--subagents/progress.lua10
-rw-r--r--subagents/spawn.lua10
-rw-r--r--subagents/workflow.lua47
11 files changed, 355 insertions, 145 deletions
diff --git a/DESIGN.md b/DESIGN.md
index 3615eb2..1042885 100644
--- a/DESIGN.md
+++ b/DESIGN.md
@@ -241,74 +241,142 @@ its ID for inspection and continuation.
Escape cancels every still-running child belonging to the current foreground
tool operation. Completed siblings remain successful. Cancellation targets
-the child streams and protocol lanes, then lets every affected tool call
-settle coherently as `cancelled` rather than abandoning the parent's batch.
+the child streams and the protocol streams those turns opened, then lets
+every affected tool call settle coherently as `cancelled` rather than
+abandoning the parent's batch.
## Generic pantograph child-job seam
libpantograph remains unaware of Lua, extensions, and subagents. Its `Agent`
and `SessionStore` abstractions already provide the conversation and
-persistence machinery. No subagent scheduler or tree policy belongs in the
-library; generic metadata round-trip fixes and conversation helpers are
-acceptable where required.
+persistence machinery, and no subagent scheduler or tree policy lives
+anywhere in Zig. The seam above it has three layers: a Lua binding
+(libpanto-lua) that adds generic job, metadata, and tool-control primitives
+usable by any embedder; a thin surface in pantograph itself (`panto.ext`) for
+the few things that need the running process or its credentials; and this
+rock, which is Lua policy written entirely on those two layers. Nothing
+subagent-specific exists below the rock.
-Pantograph supplies a generic asynchronous Agent job to Lua, conceptually:
+The binding's async job is the generic replacement for a hand-rolled worker
+thread:
```lua
-local job = panto.ext.spawn_agent {
- prompt = "Inspect the auth flow.",
- store = child_store,
- session_id = prior_id, -- nil creates a new store session
- system_messages = initial_system_messages, -- new sessions only
- model = "anthropic:sonnet",
- reasoning = "high",
+local job = agent:run_async {
+ prompt = "...", -- or blocks, mirroring agent:run
+ metadata = { ... }, -- optional user-message metadata, a JSON
+ -- object (array-shaped tables are refused)
+ dispatch_tools = false, -- optional, default true: false ends the
+ -- turn at the first assistant response
+ -- instead of dispatching its tool calls
+ wake_fd = fd, -- optional: one byte is written on every
+ -- event and on settle
}
-local result = job:await()
--- result.id, result.status, result.text, result.error, result.resumable
+job:next_event() -- -> event table | nil (non-blocking; owned copy)
+job:result() -- -> settled result table | nil while running
+job:request_cancel()
+job:close() -- joins the pump and frees the settled result; a
+ -- caller must read and cache job:result() first
```
-`spawn_agent` starts immediately. `await` yields its Lua coroutine. The host,
-not the extension, supplies the resolved provider configuration, inherited
-tool and protocol proxies, worker lifecycle, progress routing, cancellation,
-and the global concurrency bound. The store userdata remains pinned until its
-jobs settle.
+A pump thread owned by the job, not by the caller, drives the blocking turn
+to completion, copies each event off the stream before the next pull frees
+it, and buffers it under a mutex. The `wake_fd` contract is deliberately
+loop-agnostic — a luv consumer arms `uv.new_poll(fd)`, any other reactor
+selects on it — so no loop dependency reaches into the binding. Tool dispatch
+inside the turn is unchanged libpantograph behavior: when the job's agent
+carries tools registered through pantograph's Lua tool source (the primary's
+own extension tools, copied over via `agent:set_tools`), `invoke_batch` posts
+to the Lua owner thread through the same multiplexed executor a primary's
+batch uses (below), bound to that job's agent as its lane. `dispatch_tools =
+false` is the generic knob that makes a one-shot structured worker possible
+without any host involvement: the settled result carries the assistant's
+tool calls (id, name, and raw input JSON) unresolved, and no second model
+round runs.
-Pantograph also exposes the current primary session ID and resolved per-cwd
-session directory to the extension. It can then construct the nested child
-store without duplicating XDG, `PANTO_SESSION_DIR`, or cwd-encoding logic.
+Two more binding primitives complete the generic surface. Message metadata —
+`conv:message_metadata(i)` / `conv:set_message_metadata(i, tbl)`, plus an
+optional `metadata` argument on `add_system_message`/`add_user_message` —
+reads and writes the same JSON-object metadata the disk format already
+round-trips, on owned and borrowed conversations alike; the rock uses it for
+both the subagent manifest and the per-turn model/reasoning stamp, with no
+separate sidecar record. Tool control — `agent:tools()` returns a mutable
+copy of an agent's declarations, `agent:set_tools(decls)` replaces them, and
+`agent:set_config` additionally accepts `tool_choice` to force a named tool —
+lets the rock copy the primary's tools onto a child and drop `subagents.*`,
+or hand a child exactly one synthetic, call-refusing declaration for a
+one-shot worker's output tool. Dispatch is name-keyed on the shared runtime
+tool source, so a copied declaration reaches the same handler regardless of
+which agent it is registered on.
+
+pantograph adds only what requires the running process or its credentials.
+`panto.ext.resolve_model { model = "provider:alias", reasoning = label,
+tool_choice = ... }` returns an **opaque config userdata** accepted by
+`panto.agent { config = ... }` and `agent:set_config`; resolution reuses the
+existing provider/model registries and reasoning pipeline, fails before
+inference, and the userdata exposes only `model`/`reasoning`/`style`/
+`wire_model` — credentials never reach Lua. This is the one piece of the seam
+that could not be a binding API. It, `panto.ext.session_info()`, and
+`panto.ext.models` all read from one feature-neutral, read-only session
+record: the provider and model registries, the primary's already-credentialed
+live provider config, the current model label, registered extension
+protocols, and the session's id and directory. `panto.ext.on("turn_start" |
+"turn_interrupt" | "turn_end", fn)` fires around every turn on the primary
+session — interrupt before the pump parks on Escape or Ctrl+C, end on every
+exit path with a reason — generic and observe-only; the rock is one
+subscriber among any number that could exist, cancelling every live child job
+on interrupt and closing them on end. `event:set_component(tbl)` returns a
+handle (`h:invalidate()`, `h:alive()`) so a component that mutates after it is
+set — a progress card gaining a line — can ask for a repaint instead of
+rendering once and going stale.
+
+Everything else — the concurrency gate and queue, child store layout and
+directory creation on top of `SessionStore`, manifest and resume-default
+extraction from message metadata, tool filtering, one-shot worker
+construction, cancellation propagation, and progress rendering — is Lua
+policy in the rock, built entirely on the primitives above. A different
+extension could build an unrelated concurrent-job feature on exactly the same
+seam.
## Shared Lua runtime executor
All agents in one primary session share its Lua state, activated extensions,
-and luv loop. Children do not create Lua states, reload rocks, or reactivate
-extensions.
+and libuv loop. Children do not create Lua states, reload rocks, or
+reactivate extensions.
-The current runtime's singular in-flight `current_batch` must become a
-multiplexed executor:
+The runtime multiplexes tool batches rather than tracking one global
+in-flight batch — the primary's and one per in-flight child agent can be live
+at once, all executing on the single Lua owner thread:
1. Parent tool handlers start child jobs and yield.
2. Child workers drive ordinary libpantograph streams.
3. Built-in providers run directly on those workers.
-4. A child Lua tool or Lua protocol call is posted to a thread-safe runtime
- queue, waking the shared loop through a pantograph-owned `uv_async_t`.
-5. The runtime executes the callback as a coroutine on the sole Lua owner
- thread; async extension code may yield normally.
-6. Completion returns to the waiting child worker, while unrelated lanes keep
- making progress.
+4. A child Lua tool call is posted to a thread-safe runtime queue, waking the
+ shared loop through a pantograph-owned `uv_async_t`. A Lua protocol call
+ made on behalf of a child travels the same queue.
+5. The runtime executes a tool callback as a coroutine on the sole Lua owner
+ thread, where async extension code may yield normally. A protocol callback
+ is posted the same way but runs to completion under `pcall` on that
+ thread: it cannot yield.
+6. Completion returns to the waiting child worker, while unrelated batches
+ keep making progress.
-Tool batches carry explicit identity; result recording cannot consult one
-global current batch. Every proxy source is lane-bound, and `panto.ext.agent`
-resolves to the responsible Agent while that lane's coroutine runs. Module
-globals remain shared intentionally. A filtered declaration view omits
-`subagents.*` from children without reloading extensions.
+Tool batches carry explicit identity, so result recording always addresses
+the right batch rather than one global slot. Each batch is bound to the agent
+that raised it, and `panto.ext.agent` resolves to that Agent while the
+batch's coroutine runs. Protocol sources are not bound this way — one
+registered protocol serves every agent in the session, and its streams keep
+the turns apart. Module globals remain shared intentionally. A
+child's tool declarations are exactly whatever its own `agent:set_tools` call
+put there — there is no host-side filtered view to keep in sync.
## Extension-provided protocol concurrency
-Every Agent job receives a protocol lane. A lane-bound `ProtocolSource` proxy
-routes stream pulls, cancellation, and closure to that job. This remains a
-pantograph implementation detail rather than a libpantograph or workflow
-concept.
+A registered protocol is process-wide, not per-child: the primary and every
+child job reach it through the one `ProtocolSource` the session installed.
+What keeps concurrent turns apart is the stream, not a per-job lane — each
+`open` returns its own. This remains a pantograph implementation detail
+rather than a libpantograph or workflow concept.
Each call to an extension-provided protocol's `open` creates an independently
managed stream, conceptually:
@@ -331,9 +399,21 @@ panto.ext.register_protocol {
Mutable turn state belongs to the returned stream or its closure, never a
module-global "current session." Multiple streams from one registered
-protocol may be open and yield concurrently. Protocol extensions use
-coroutine waits rather than nested event-loop runs; the shared pantograph
-scheduler remains the sole event-loop owner.
+protocol may be open concurrently and interleave between calls. A protocol
+body — `open`, or a stream's `next`, `cancel_turn`, or `close` — runs to
+completion on the loop thread under `pcall`: it cannot yield, and it must not
+block, because that thread is the one every other stream and tool batch needs
+back. Awaiting belongs in tool handlers, which do run as coroutines and may
+park on luv work; the shared pantograph scheduler remains the sole event-loop
+owner.
+
+Cancellation and compaction are scoped to the stream, not the registration.
+Abandoning a turn calls that stream's own `cancel_turn`; a protocol that
+defines none falls back to the registration-level `cancel_turn`, which is
+process-wide by construction. A child compacting its own conversation does
+not reset protocol sessions the primary and its siblings are streaming on;
+only the session's own teardown runs a protocol's registration-level
+`close`.
Every child turn, including a resumed child, receives a fresh protocol stream.
The loaded Panto JSONL conversation is canonical and is present in the open
@@ -516,7 +596,7 @@ Pantograph checks must prove:
- built-in and Lua protocol children can run together;
- lane-local `panto.ext.agent` resolves to the correct child;
- progress events remain tagged to the correct child;
-- cancellation settles every affected stream and lane coherently;
+- cancellation settles every affected stream and tool batch coherently;
- one failed child does not cancel successful siblings; and
- child tool declarations omit `subagents.*` while shared extension
activation happens exactly once.
@@ -543,12 +623,14 @@ generated slash-command registration.
Extension-protocol checks must run multiple mocked streams concurrently and
prove that startup, events, tool calls, cancellation, transcript bootstrap,
-and closure remain lane-local.
+and closure stay scoped to the stream that opened them — including that
+cancelling one turn leaves its siblings' streams untouched.
## Delivery order
1. Add generic concurrent Agent jobs, global bounding, multiplexed Lua
- dispatch, protocol lanes, nested progress, and foreground cancellation.
+ dispatch, per-stream protocol concurrency, nested progress, and foreground
+ cancellation.
2. Add layered Markdown profiles, model/reasoning resolution, bounded catalog
queries, durable tree-scoped child stores, continuation, and
`subagents.run`/`subagents.models`.
diff --git a/panto-subagents-0.1.0-1.rockspec b/panto-subagents-0.1.0-1.rockspec
index 073ff56..693629e 100644
--- a/panto-subagents-0.1.0-1.rockspec
+++ b/panto-subagents-0.1.0-1.rockspec
@@ -17,12 +17,13 @@
-- 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.
+-- Deliberately absent: `jsonschema`. It depends on `lrexlib-pcre`, which needs
+-- a system PCRE that stock macOS does not have, and a failed dependency install
+-- would take the whole extension down silently (panto logs and skips a rock
+-- that fails to load). subagents/workflow.lua therefore validates structured
+-- workflow output with its own built-in JSON Schema subset and never probes for
+-- the rock: one validator, so the same child output cannot pass on one machine
+-- and fail on another.
rockspec_format = "3.0"
package = "panto-subagents"
diff --git a/spec/fake_ext.lua b/spec/fake_ext.lua
index 5ec4c06..dfdfdb3 100644
--- a/spec/fake_ext.lua
+++ b/spec/fake_ext.lua
@@ -32,11 +32,12 @@
--
-- 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.
+-- the previous fake used for "first" awaits. Real wake pipes are opened and
+-- polled (the job machinery refuses to start a child without one), but nothing
+-- ever runs the loop and nothing writes a wake byte; the specs call await from
+-- a plain script, which cannot park, so awaiting drains its jobs in place and
+-- those polls are what turn into progress. `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
@@ -52,21 +53,17 @@ 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.
+-- `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 — pipes included — delegates to the real luv, so a spec exercises the
+-- same wake-pipe arming production does. 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
@@ -162,7 +159,9 @@ 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.
+-- given, is the child record seeded system messages are mirrored onto. A
+-- scripted message may instead carry `metadata_error =`, which is how the
+-- binding reports a stored record it cannot decode: by raising.
local function new_conversation(scripted, record)
local messages = {}
for index, message in ipairs(scripted or {}) do
@@ -170,6 +169,7 @@ local function new_conversation(scripted, record)
role = message.role or "user",
blocks = message.blocks or { { type = "text", text = message.text or "" } },
metadata = message.metadata,
+ metadata_error = message.metadata_error,
}
end
return setmetatable({ _messages = messages, _record = record }, conv_mt)
@@ -194,6 +194,9 @@ function conv_mt:message_metadata(index)
if message == nil then
return nil
end
+ if message.metadata_error then
+ error("panto: " .. message.metadata_error, 2)
+ end
return message.metadata
end
diff --git a/spec/test_jobs.lua b/spec/test_jobs.lua
index ce0525c..de36ab5 100644
--- a/spec/test_jobs.lua
+++ b/spec/test_jobs.lua
@@ -3,9 +3,10 @@
--
-- 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.
+-- policy. Real wake pipes are armed (a job that cannot get one is refused), but
+-- nothing writes to them and no loop runs: a plain script cannot park, so
+-- awaiting drains its jobs in place and `settle = N` means "settles on the Nth
+-- poll", which is how the ordering cases stay deterministic without a loop.
local fake = require("spec.fake_ext")
local jobs = require("subagents.jobs")
@@ -42,9 +43,7 @@ local function starter()
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
@@ -261,4 +260,59 @@ return {
assert(tostring(err):find("the host refused", 1, true), tostring(err))
end)
end },
+
+ -- Under luv the wake pipe is not optional: without it nothing would ever
+ -- return the owner thread to the loop the child's own tool batches need.
+ { "a child whose wake pipe cannot be armed is refused instead of started", function()
+ local ok, uv = pcall(require, "luv")
+ if not ok or type(uv) ~= "table" then
+ return "skip", "luv is not installed"
+ end
+ with_jobs(function()
+ local start, built = starter()
+ uv.pipe = function()
+ return nil, "EMFILE"
+ end
+ local handle, err = start("alpha")
+ uv.pipe = nil -- back to the real luv through the harness metatable
+ assert(handle == nil, "a child with no wake pipe must not start")
+ assert(tostring(err):find("the subagent wake pipe could not be armed", 1, true), tostring(err))
+ assert(#built == 0, "and its turn is never built")
+ end)
+ end },
+
+ -- close_all cannot join a pump that is still up, so that child is still
+ -- running against the same bound and still owns its session file.
+ { "a closing child holds its slot until it settles", function()
+ with_jobs(function()
+ local start, built = starter()
+ local closing = assert(start("alpha", { settle = 99 }))
+ jobs.close_all()
+
+ local queued = assert(start("beta"))
+ assert(#built == 1, "the closing child still holds the only slot, saw " .. #built)
+
+ jobs.await({ closing }, "all")
+ assert(#built == 2, "the slot comes back when the closing child settles")
+
+ jobs.await({ queued }, "all")
+ local last = assert(start("gamma"))
+ assert(#built == 3, "and the bound is not leaked once everything has settled")
+ jobs.await({ last }, "all")
+ end, 1)
+ end },
+
+ { "a closing child still reports its id as active", function()
+ with_jobs(function()
+ local start = starter()
+ local handle = assert(start("alpha", { id = "child-1", settle = 99 }))
+ assert(jobs.active("child-1"), "a running child is active")
+
+ jobs.close_all()
+ assert(jobs.active("child-1"), "a cancelled child still owns its session file")
+
+ jobs.await({ handle }, "all")
+ assert(not jobs.active("child-1"), "the settle releases the id")
+ end)
+ end },
}
diff --git a/spec/test_run.lua b/spec/test_run.lua
index 706faff..e4bb038 100644
--- a/spec/test_run.lua
+++ b/spec/test_run.lua
@@ -254,6 +254,30 @@ return {
end)
end },
+ -- The binding reports a stored record it cannot decode by raising. A
+ -- malformed message is skipped, and nothing here raises out to the tool.
+ { "stored metadata that cannot be decoded is skipped, not raised", function()
+ with_host(function(handle, profiles)
+ local stored = stored_reviewer()
+ for _, message in ipairs(stored) do
+ if message.metadata then
+ message.metadata = nil
+ message.metadata_error = "message_metadata: invalid JSON"
+ end
+ end
+ handle.add_session("0198-child", stored)
+ handle.queue({ output = "second turn" })
+
+ local text = run.handle({ id = "0198-child", prompt = "carry on" }, profiles)
+ assert(not text:find("Error:", 1, true), "a malformed record is not a tool error:\n" .. text)
+ has(text, "second turn")
+ has(text, "agent: ?") -- an unreadable manifest names no agent
+ assert(handle.spawns[1].model == "anthropic:sonnet",
+ "an unreadable stored default falls back to the primary's model, got " ..
+ tostring(handle.spawns[1].model))
+ 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())
diff --git a/spec/test_workflow.lua b/spec/test_workflow.lua
index c5ba06c..5d58c01 100644
--- a/spec/test_workflow.lua
+++ b/spec/test_workflow.lua
@@ -231,6 +231,53 @@ return {
end)
end },
+ -- One validator, always the built-in one: a schema must not be judged by
+ -- whether an undeclared rock happens to be installed on this machine.
+ { "validation never consults the jsonschema rock", function()
+ if not json_available() then
+ return "skip", "dkjson is not installed"
+ end
+ local restore = package.loaded.jsonschema
+ package.loaded.jsonschema = {
+ generate_validator = function()
+ return function()
+ return false, "the rock must not be consulted"
+ end
+ end,
+ }
+ local ok, err = pcall(with_host, function(handle, profiles)
+ handle.queue_for("alpha", { structured_json = '{"items":["x"]}' })
+ 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 == "completed", tostring(result.error))
+ assert(result.output.items[1] == "x", "the built-in validator accepted it")
+ end)
+ package.loaded.jsonschema = restore
+ if not ok then
+ error(err, 0)
+ end
+ end },
+
+ { "a structured worker whose output tool carried no arguments fails as missing", function()
+ with_host(function(handle, profiles)
+ handle.queue_for("alpha", { structured_json = "" })
+ 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, "structured output missing")
+ 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" })
diff --git a/subagents/jobs.lua b/subagents/jobs.lua
index bab8f30..3f9a8af 100644
--- a/subagents/jobs.lua
+++ b/subagents/jobs.lua
@@ -14,10 +14,15 @@
-- 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.
+-- the parked thread is the one the resume goes to.
+--
+-- The wake pipe is therefore mandatory wherever luv is: a started job whose
+-- pipe or poll could not be armed is a start failure, not a degraded job.
+-- The drain-and-sleep fallback below only ever serves a luv-less host or a
+-- caller with no coroutine to park (a plain script), because it cannot serve a
+-- child that dispatches tools: those tool batches are posted to this very
+-- thread, so a loop that never returns to uv would wait for work only it can
+-- do. A visible "could not be armed" beats an unrecoverable hang.
--
-- 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
@@ -127,7 +132,9 @@ local function settle(handle, raw)
end
end
handle.settled = result
- if handle.state == "running" then
+ -- A closing job still holds its slot: close_all could not join its pump, so
+ -- the child is still up. It gives the slot back here, once it really exits.
+ if handle.state == "running" or handle.state == "closing" then
running = running - 1
end
handle.state = "settled"
@@ -142,6 +149,21 @@ end
local function launch(handle)
handle.fds = open_pipe()
+
+ -- Arm the wake before the child exists, so a host that cannot give us one
+ -- never leaves a pump running with nothing to drain it.
+ if ok_uv then
+ if handle.fds == nil then
+ return false, "the subagent wake pipe could not be armed"
+ end
+ local armed, poll = pcall(uv.new_poll, handle.fds.read)
+ if not armed or not poll then
+ close_pipe(handle)
+ return false, "the subagent wake pipe could not be armed"
+ end
+ handle.poll = poll
+ end
+
local ok, job, err = pcall(handle.spec.build, handle.fds and handle.fds.write or nil)
if not ok then
close_pipe(handle)
@@ -156,15 +178,11 @@ local function launch(handle)
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
+ if handle.poll then
+ handle.poll:start("r", function()
+ drain(handle)
+ wake()
+ end)
end
return true
end
@@ -265,7 +283,7 @@ end
--
-- startspec = {
-- build = function(wake_fd) -> job | nil, err -- calls agent:run_async
--- label = string?, id = string?, one_shot = boolean?
+-- id = string?
-- 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
@@ -279,9 +297,7 @@ function M.start(spec)
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
@@ -306,15 +322,16 @@ function M.start(spec)
return handle
end
--- True while a child with this id has a turn in flight. A child cancelled by
--- close_all is not one: its turn belongs to the turn that ended, and the next
--- one must not be refused because that pump has not finished exiting yet.
+-- True while a child with this id has a turn in flight, including one that
+-- close_all cancelled but whose pump has not exited yet: it still owns that
+-- child's session file, and a second writer over the same file loses data. The
+-- refusal is transient — the pump's settle clears it on the next drain.
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 and handle.state ~= "closing" then
+ if handle.id == id and handle.settled == nil then
return true
end
end
@@ -439,8 +456,10 @@ function M.close_all()
end
end
- -- Whatever is still unsettled stays live so its poll (and any fallback
- -- drain) still reaches it; the next close_all sweeps up what settled since.
+ -- Whatever is still unsettled stays live with its wake poll still armed —
+ -- start() refuses a job that has none — so the byte its pump writes on the
+ -- way out still drives the settle that closes it and frees its pipe. The
+ -- next close_all sweeps up whatever settled since.
local closing = {}
for _, handle in ipairs(live) do
if handle.job and handle.settled == nil then
@@ -453,7 +472,9 @@ function M.close_all()
end
end
live, queued, waiters = closing, {}, {}
- running = 0
+ -- Those children are still running against the same bound; each releases
+ -- its slot in settle() when its pump finally exits.
+ running = #closing
end
return M
diff --git a/subagents/luatool.lua b/subagents/luatool.lua
index 089c67b..8aaa5c9 100644
--- a/subagents/luatool.lua
+++ b/subagents/luatool.lua
@@ -54,7 +54,6 @@ 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 = {}
@@ -139,8 +138,6 @@ local function format_return(value)
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)
diff --git a/subagents/progress.lua b/subagents/progress.lua
index 274e2a8..087a3c3 100644
--- a/subagents/progress.lua
+++ b/subagents/progress.lua
@@ -193,12 +193,10 @@ end
-- `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
+ -- The event is host userdata; an unknown field answers nil rather than
+ -- raising, and activation already refused a host too old to have these.
+ local name = event.tool_name
+ if type(name) ~= "string" or name:sub(1, #TOOL_PREFIX) ~= TOOL_PREFIX then
return
end
if type(event.set_component) ~= "function" then
diff --git a/subagents/spawn.lua b/subagents/spawn.lua
index a83e442..b355d9a 100644
--- a/subagents/spawn.lua
+++ b/subagents/spawn.lua
@@ -230,8 +230,8 @@ local function read_stored(conv)
local manifest
for index = 1, #messages do
if messages[index].role == "system" then
- local metadata = conv:message_metadata(index)
- if type(metadata) == "table" then
+ local got, metadata = try(conv.message_metadata, conv, index)
+ if got and type(metadata) == "table" then
manifest = metadata
break
end
@@ -241,8 +241,8 @@ local function read_stored(conv)
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
+ local got, metadata = try(conv.message_metadata, conv, index)
+ local mine = got and type(metadata) == "table" and metadata.subagents or nil
if type(mine) == "table" then
defaults.model = nonempty(mine.model)
defaults.reasoning = nonempty(mine.reasoning)
@@ -488,9 +488,7 @@ function M.spawn(spec)
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,
diff --git a/subagents/workflow.lua b/subagents/workflow.lua
index b1d3e46..3cf58d9 100644
--- a/subagents/workflow.lua
+++ b/subagents/workflow.lua
@@ -40,12 +40,14 @@
-- * 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.
+-- against output.schema by the one validator below: the JSON Schema subset
+-- a child's output tool actually uses, ignoring keywords it does not know.
+-- There is deliberately no second, rock-dependent path -- `jsonschema` needs
+-- lrexlib-pcre and a system PCRE that stock macOS lacks, so it is not a
+-- declared dependency, and a validator picked by whether a rock happens to
+-- be installed would make the same output pass here and fail there. 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.
@@ -58,8 +60,6 @@ local M = {}
local workflow_mt = { __name = "subagents.workflow" }
-M.workflow_mt = workflow_mt
-
-- ---------------------------------------------------------------------------
-- Host seam access
-- ---------------------------------------------------------------------------
@@ -117,16 +117,15 @@ local function json_encode(value)
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.
+-- The 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
@@ -259,23 +258,6 @@ local function check_schema(value, schema, path)
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
-- ---------------------------------------------------------------------------
@@ -312,8 +294,11 @@ local function shape_result(result, handle)
return shaped
end
+ -- An empty tool input is a real provider case (a chat-style provider
+ -- finalizes an argument-less call with ""), and it is not a validation
+ -- failure: nothing was produced to validate.
local raw = shaped.structured_json
- if type(raw) ~= "string" or raw == "" then
+ if raw == nil or raw == "" then
return fail(shaped, "structured output missing: the child produced no structured result")
end
@@ -322,7 +307,7 @@ local function shape_result(result, handle)
return fail(shaped, "structured output failed validation: " .. tostring(decoded))
end
- local valid, message = validator_for(schema)(decoded)
+ local valid, message = check_schema(decoded, schema, "output")
if not valid then
return fail(shaped, "structured output failed validation: " .. tostring(message or "schema mismatch"))
end