summaryrefslogtreecommitdiff
path: root/subagents/progress.lua
diff options
context:
space:
mode:
authort <t@tjp.lol>2026-08-16 20:42:43 -0600
committert <t@tjp.lol>2026-08-17 20:31:29 -0600
commit4f0a91ef55fe96835172bdad34feec1e2a0a0977 (patch)
treeed4f3e86575aa6243043bc22f8037be153a609c6 /subagents/progress.lua
parentc1ab34754d3f3695fafd344fe1a181ecf0740761 (diff)
subagents extension on the generic host surfaces
The rock now owns all subagent policy on top of libpanto-lua's generic APIs: children are ordinary panto.agent instances over rock-constructed stores, started with agent:run_async and awaited by arming uv.new_poll on each job's wake_fd from the tool handler's coroutine. subagents/jobs.lua carries the session policy the host used to own: the concurrency gate (4 running, FIFO queue, cancel-while-queued never starts), the await contract (results in input order; "first" returns settled plus remaining by identity), and settle-time shaping. subagents/spawn.lua seeds new children (primary system context, child role, profile body with manifest metadata), resolves model/reasoning through panto.ext.resolve_model, filters subagents.* out of the inherited tool set via agent:set_tools, and reads resume defaults back from stored message metadata. One-shot structured workers are a null_store agent with a declaration-only output tool, tool_choice forced, dispatch_tools=false. subagents/progress.lua renders per-tool-entry cards through the component handle's invalidate seam; turn_interrupt cancels live children, turn_end closes them. Spec suite rewritten against fakes of the new surfaces (98 cases), including gate/queue/cancel bounds, resume-default extraction, one-shot capture via unresolved tool calls, tool filtering, and manifest seeding.
Diffstat (limited to 'subagents/progress.lua')
-rw-r--r--subagents/progress.lua260
1 files changed, 260 insertions, 0 deletions
diff --git a/subagents/progress.lua b/subagents/progress.lua
new file mode 100644
index 0000000..274e2a8
--- /dev/null
+++ b/subagents/progress.lua
@@ -0,0 +1,260 @@
+-- Live progress cards for in-flight children.
+--
+-- While the primary model is blocked on one of the subagents.* tools, its
+-- children are streaming. This folds each child's event stream into one small
+-- card and renders the set as the component of the tool-call entry that
+-- started them, so several concurrent children stay visually distinct and the
+-- cards disappear with the entry they belong to. The events are presentation
+-- data only: nothing here ever reaches a conversation.
+--
+-- Linking a card to the right entry. The host fires `tool_call_complete` with
+-- the tool-use id just before dispatching that call, and hands the same id to
+-- the handler as `context.tool_call_id`; `claim` records the board under the
+-- id, `bind` ties the running handler coroutine to it, and `card` finds the
+-- board of whichever coroutine is asking. A workflow callback runs on its
+-- handler's own coroutine, so a card raised deep inside one still lands on the
+-- entry the model can see.
+--
+-- Everything degrades to a no-op card: in print mode, in a test, or under a
+-- host with no component machinery there is no board to attach to, and a child
+-- still runs exactly the same.
+--
+-- Rendering is deliberately small — a four-line ring per card, truncated to the
+-- terminal width — because several children run at once and the panel must not
+-- push the transcript off the screen.
+
+local MAX_CARD_LINES = 4
+local MAX_LINE_BYTES = 1024
+local BODY_INDENT = " "
+local TOOL_PREFIX = "subagents."
+
+local GLYPHS = {
+ running = "◷",
+ completed = "✔",
+ failed = "✖",
+ cancelled = "⊘",
+}
+
+local M = {}
+
+-- Boards keyed by tool-call id, and the board bound to each handler coroutine.
+-- Weak keys on the second so a finished handler's binding disappears with it.
+local boards = {}
+local bound = setmetatable({}, { __mode = "k" })
+
+-- ---------------------------------------------------------------------------
+-- Text handling
+-- ---------------------------------------------------------------------------
+
+-- Control bytes would move the cursor or confuse the renderer's width
+-- accounting, so they become spaces. UTF-8 continuation bytes are >= 0x80 and
+-- pass through untouched.
+local function sanitize(text)
+ return (text:gsub("[%z\1-\31\127]", " "))
+end
+
+local function truncate(text, width)
+ if width == nil or width < 1 then
+ return text
+ end
+ local length = utf8.len(text)
+ if length == nil or length <= width then
+ return text
+ end
+ return text:sub(1, (utf8.offset(text, width + 1) or (#text + 1)) - 1)
+end
+
+-- ---------------------------------------------------------------------------
+-- Cards
+-- ---------------------------------------------------------------------------
+
+local card_mt = {}
+card_mt.__index = card_mt
+
+local function repaint(card)
+ local board = card.board
+ if board and board.handle then
+ pcall(board.handle.invalidate, board.handle)
+ end
+end
+
+local function adopt(card, line)
+ card.lines[#card.lines + 1] = line
+ while #card.lines > MAX_CARD_LINES do
+ table.remove(card.lines, 1)
+ end
+end
+
+-- A discrete, already-complete line: a tool marker, a model label, an error.
+local function marker(card, prefix, text)
+ card.partial = false
+ if text == nil or text == "" then
+ return
+ end
+ adopt(card, prefix .. sanitize(text:sub(1, MAX_LINE_BYTES)))
+end
+
+-- Fold a text delta in, breaking it on newlines. A chunk with no newline leaves
+-- the tail line open so the next delta continues it.
+local function append_text(card, text)
+ local rest = text
+ while rest ~= "" do
+ local newline = rest:find("\n", 1, true)
+ local chunk = newline and rest:sub(1, newline - 1) or rest
+ if card.partial and #card.lines > 0 then
+ local index = #card.lines
+ local grown = card.lines[index] .. sanitize(chunk)
+ card.lines[index] = grown:sub(1, MAX_LINE_BYTES)
+ elseif chunk ~= "" then
+ adopt(card, sanitize(chunk:sub(1, MAX_LINE_BYTES)))
+ end
+ card.partial = newline == nil
+ rest = newline and rest:sub(newline + 1) or ""
+ end
+end
+
+-- One run_async event. `content_delta` carries only an index, so the block type
+-- comes from the `block_start` that opened it — the same flag the v0 pump kept.
+function card_mt:event(event)
+ if type(event) ~= "table" then
+ return
+ end
+ local kind = event.type
+ if kind == "block_start" then
+ self.text_block = event.block_type == "text"
+ elseif kind == "content_delta" then
+ if self.text_block and type(event.delta) == "string" then
+ append_text(self, event.delta)
+ end
+ elseif kind == "tool_details" then
+ marker(self, "⚒ ", event.name)
+ elseif kind == "tool_dispatch_complete" or kind == "message_complete" then
+ self.partial = false
+ else
+ return
+ end
+ repaint(self)
+end
+
+-- The child settled. Only a failure or a cancellation gets a closing line; a
+-- completed child's report is the tool result the model already sees.
+function card_mt:done(status, message)
+ self.status = GLYPHS[status] and status or "failed"
+ if self.status ~= "completed" then
+ marker(self, "", message)
+ else
+ self.partial = false
+ end
+ repaint(self)
+end
+
+local NOOP = setmetatable({ lines = {} }, {
+ __index = {
+ event = function() end,
+ done = function() end,
+ },
+})
+
+-- ---------------------------------------------------------------------------
+-- Boards (one per tool-call entry)
+-- ---------------------------------------------------------------------------
+
+local function render(board, width)
+ local out = {}
+ if #board.cards == 0 or (width or 0) <= 4 then
+ return out
+ end
+ out[#out + 1] = ""
+ for _, card in ipairs(board.cards) do
+ local sid = card.sid ~= "" and (" " .. card.sid) or ""
+ out[#out + 1] = truncate(string.format("%s %s%s — %s",
+ GLYPHS[card.status] or GLYPHS.running, card.label, sid, card.status), width)
+ for _, line in ipairs(card.lines) do
+ out[#out + 1] = BODY_INDENT .. truncate(line, width - #BODY_INDENT)
+ end
+ end
+ return out
+end
+
+local function new_board()
+ local board = { cards = {}, handle = nil }
+ board.component = {
+ render = function(_, width)
+ return render(board, width)
+ end,
+ }
+ return board
+end
+
+-- ---------------------------------------------------------------------------
+-- Host wiring
+-- ---------------------------------------------------------------------------
+
+-- `tool_call_complete` for one of our tools: claim that entry's component so
+-- the cards the handler is about to raise have somewhere to render.
+function M.claim(event)
+ -- The event is host userdata; a host that predates any of these fields
+ -- answers nil, and one that predates the whole object cannot be indexed.
+ local ok, name = pcall(function()
+ return event.tool_name
+ end)
+ if not ok or type(name) ~= "string" or name:sub(1, #TOOL_PREFIX) ~= TOOL_PREFIX then
+ return
+ end
+ if type(event.set_component) ~= "function" then
+ return
+ end
+
+ local key = event.id or name
+ local board = new_board()
+ local attached, handle = pcall(event.set_component, event, board.component)
+ if not attached then
+ return
+ end
+ board.handle = handle
+ boards[key] = board
+end
+
+-- Tie the running handler coroutine to the entry it was dispatched for.
+function M.bind(context)
+ local key = type(context) == "table" and context.tool_call_id or nil
+ if key == nil then
+ return
+ end
+ bound[coroutine.running()] = key
+end
+
+-- card(label, id, detail) -> card
+--
+-- The card of the entry whose handler is running, or an inert one when there is
+-- no such entry (print mode, a plain script, a host without components).
+function M.card(label, id, detail)
+ local key = bound[coroutine.running()]
+ local board = key and boards[key]
+ if board == nil or (board.handle and board.handle.alive and not board.handle:alive()) then
+ return NOOP
+ end
+
+ local card = setmetatable({
+ board = board,
+ label = tostring(label or "subagent"),
+ sid = type(id) == "string" and id:sub(1, 8) or "",
+ status = "running",
+ lines = {},
+ partial = false,
+ text_block = false,
+ }, card_mt)
+ board.cards[#board.cards + 1] = card
+ marker(card, "↳ ", detail)
+ repaint(card)
+ return card
+end
+
+-- End of turn: the entries are gone, and the primary's own tool results are the
+-- durable record from here on.
+function M.reset()
+ boards = {}
+ bound = setmetatable({}, { __mode = "k" })
+end
+
+return M