diff options
| -rw-r--r-- | DESIGN.md | 178 | ||||
| -rw-r--r-- | panto-subagents-0.1.0-1.rockspec | 13 | ||||
| -rw-r--r-- | spec/fake_ext.lua | 35 | ||||
| -rw-r--r-- | spec/test_jobs.lua | 64 | ||||
| -rw-r--r-- | spec/test_run.lua | 24 | ||||
| -rw-r--r-- | spec/test_workflow.lua | 47 | ||||
| -rw-r--r-- | subagents/jobs.lua | 69 | ||||
| -rw-r--r-- | subagents/luatool.lua | 3 | ||||
| -rw-r--r-- | subagents/progress.lua | 10 | ||||
| -rw-r--r-- | subagents/spawn.lua | 10 | ||||
| -rw-r--r-- | subagents/workflow.lua | 47 |
11 files changed, 355 insertions, 145 deletions
@@ -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 |
