summaryrefslogtreecommitdiff
path: root/DESIGN.md
diff options
context:
space:
mode:
authort <t@tjp.lol>2026-08-17 23:35:36 -0600
committert <t@tjp.lol>2026-08-18 00:40:46 -0600
commit372ef8ff40991644ec2654c61328f31779f4ad21 (patch)
tree9bc69a578995e14f154202badcdbe0021a39c467 /DESIGN.md
parent7f8fdd8e868fb5fad71eacdf0c4fd0a97fe9c6ee (diff)
Validation-pass fixes; DESIGN.md describes the shipped seam
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.
Diffstat (limited to 'DESIGN.md')
-rw-r--r--DESIGN.md178
1 files changed, 130 insertions, 48 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`.