From 372ef8ff40991644ec2654c61328f31779f4ad21 Mon Sep 17 00:00:00 2001 From: t Date: Mon, 17 Aug 2026 23:35:36 -0600 Subject: Validation-pass fixes; DESIGN.md describes the shipped seam MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit jobs: a child whose wake pipe cannot be armed is refused up front instead of started into a state nothing can wake (the luv-less drain loop survives only for hosts without a loop); a closing child keeps its concurrency slot and its id's exclusivity until the pump actually exits, so teardown can no longer over-admit new children or let two turns share one session file. Resume-metadata reads honor the plain-error contract on a malformed store. workflow: the built-in schema subset validator is the only validator — the jsonschema probe made behavior depend on an undeclared rock (see rockspec: that dependency is deliberately rejected); dead exports and the unreachable half of the structured-output guard are gone, keeping the empty-arguments provider case. DESIGN.md's seam sections now describe the shipped division: binding-level async jobs and tool control, host-level resolve_model/ExtHost/turn events/ component handles, rock-level policy; protocol bodies run to completion on the loop thread and cannot yield, cancellation is scoped to the stream that opened it, and a child's compaction leaves protocol sessions alone. --- DESIGN.md | 182 +++++++++++++++++++++++++++++++++++++++++++++----------------- 1 file changed, 132 insertions(+), 50 deletions(-) (limited to 'DESIGN.md') 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. - -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. +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. + +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. - -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. +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, 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`. -- cgit v1.3