diff options
| author | t <t@tjp.lol> | 2026-08-18 14:18:14 -0600 |
|---|---|---|
| committer | t <t@tjp.lol> | 2026-08-18 14:20:28 -0600 |
| commit | cc69a2e431779528578211a5cb3385bb23e076bf (patch) | |
| tree | 8833171e25bb3dfb71fc4a2e0b8fe583782da764 /subagents | |
| parent | 372ef8ff40991644ec2654c61328f31779f4ad21 (diff) | |
Persist and replay subagent progress across sessions
Record durable child-turn metadata, restore bounded progress cards during
startup replay, and keep settled cards attached to transcript entries. Add
workflow-local Lua profiles and expose discovered agents and workflows in the
session header.
Diffstat (limited to 'subagents')
| -rw-r--r-- | subagents/jobs.lua | 3 | ||||
| -rw-r--r-- | subagents/luatool.lua | 74 | ||||
| -rw-r--r-- | subagents/progress.lua | 786 | ||||
| -rw-r--r-- | subagents/spawn.lua | 59 |
4 files changed, 753 insertions, 169 deletions
diff --git a/subagents/jobs.lua b/subagents/jobs.lua index 3f9a8af..a774b27 100644 --- a/subagents/jobs.lua +++ b/subagents/jobs.lua @@ -28,7 +28,8 @@ -- `job:close()` frees it. Jobs are otherwise left open until close_all() ends -- the turn, so a result can still be read after its coroutine resumed. -- --- `job:close()` also JOINS the pump, and a pump parked in a tool batch is +-- A settled consumer may close a job immediately after caching its result; +-- otherwise close_all() handles it. `job:close()` also JOINS the pump, and a pump parked in a tool batch is -- waiting on the owner thread to come back to the uv loop — the very thread -- every entry point here runs on. Closing an unsettled job would therefore -- deadlock, so nothing does: only settle() closes a job, once its pump has diff --git a/subagents/luatool.lua b/subagents/luatool.lua index 8aaa5c9..53966aa 100644 --- a/subagents/luatool.lua +++ b/subagents/luatool.lua @@ -4,7 +4,8 @@ -- workflow without writing a definition to disk. The source must evaluate to -- `subagents.workflow(function(ctx, input) ... end)`; the tool then executes it -- with the tool's `prompt` as the workflow input and formats the terminal --- results for the calling model. +-- results for the calling model. Optional inline agent profiles are overlaid +-- for this execution only; they are never persisted or added to discovery. -- -- The source is loaded in text mode only (`load(source, chunkname, "t", env)`) -- against a restricted `_ENV`. That environment holds a safe slice of the @@ -46,6 +47,7 @@ local workflow = require("subagents.workflow") local run = require("subagents.run") +local spawn = require("subagents.spawn") local MAX_JOBS = 32 local INSTRUCTION_BUDGET = 10000000 @@ -91,6 +93,69 @@ end M.build_env = build_env +-- Overlay workflow-local profiles without mutating the activation-time set. +-- Inline names intentionally win, matching normal layered profile precedence +-- while keeping their lifetime bounded to this one execute() call. +local function workflow_profiles(inline, discovered) + if inline == nil then + return discovered, nil + end + if type(inline) ~= "table" then + return nil, "agents must be an array" + end + + local count = 0 + for key in pairs(inline) do + if type(key) ~= "number" then + return nil, "agents must be an array" + end + count = count + 1 + end + if count ~= #inline then + return nil, "agents must be a dense array" + end + + local base = spawn.profiles(discovered) + local by_name = {} + for name, profile in pairs(base.by_name or {}) do + by_name[name] = profile + end + local seen = {} + for index, raw in ipairs(inline) do + if type(raw) ~= "table" then + return nil, string.format("agents[%d] must be an object", index) + end + local name = raw.name + if type(name) ~= "string" or name:match("^%s*$") then + return nil, string.format("agents[%d].name must be a non-empty string", index) + end + if seen[name] then + return nil, string.format("duplicate inline agent '%s'", name) + end + local system_prompt = raw.system_prompt + if type(system_prompt) ~= "string" or system_prompt:match("^%s*$") then + return nil, string.format("agents[%d].system_prompt must be a non-empty string", index) + end + if raw.description ~= nil and type(raw.description) ~= "string" then + return nil, string.format("agents[%d].description must be a string when given", index) + end + seen[name] = true + by_name[name] = { + name = name, + description = raw.description or "", + body = system_prompt, + layer = "workflow", + } + end + + local list = {} + for _, profile in pairs(by_name) do + list[#list + 1] = profile + end + table.sort(list, function(a, b) return a.name < b.name end) + return { list = list, by_name = by_name, warnings = base.warnings or {} }, nil +end + local function budget_hook() error("instruction budget exceeded", 0) end @@ -151,6 +216,11 @@ function M.handle(input, profiles) return "Error: source is required and must be a non-empty string" end + local profiles_for_run, profiles_err = workflow_profiles(input.agents, profiles) + if not profiles_for_run then + return "Error: " .. profiles_err + end + local chunk, load_err = load(input.source, CHUNK_NAME, "t", build_env()) if not chunk then return "Error: source did not compile: " .. tostring(load_err) @@ -167,7 +237,7 @@ function M.handle(input, profiles) local armed = nil local ran_ok, result = pcall(workflow.execute, built, input.prompt, { max_jobs = MAX_JOBS, - profiles = profiles, + profiles = profiles_for_run, on_resume = function(co) armed = co debug.sethook(co, budget_hook, "", INSTRUCTION_BUDGET) diff --git a/subagents/progress.lua b/subagents/progress.lua index 087a3c3..fb95115 100644 --- a/subagents/progress.lua +++ b/subagents/progress.lua @@ -1,32 +1,27 @@ --- 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. +-- Expandable progress boards for in-flight children, with a bounded replay +-- path that rebuilds durable child turns when a primary session is reopened. -local MAX_CARD_LINES = 4 -local MAX_LINE_BYTES = 1024 +local paths = require("subagents.paths") + +local COLLAPSED_LINES = 4 +local MAX_HISTORY_LINES = 512 +local MAX_HISTORY_BYTES = 128 * 1024 +local MAX_LINE_BYTES = 4096 +local MAX_PRESENTATION_BYTES = 32 * 1024 +local MAX_PRESENTATION_LINES = 128 +local MAX_RENDER_LINES = 4096 +local MAX_REPLAY_SESSIONS = 1024 +local MAX_REPLAY_MESSAGES = 8192 +local MAX_REPLAY_CARDS = 512 +local MAX_REPLAY_BLOCKS = 4096 +local MAX_REPLAY_BLOCK_BYTES = 64 * 1024 +local MAX_REPLAY_MESSAGE_BYTES = 256 * 1024 local BODY_INDENT = " " -local TOOL_PREFIX = "subagents." +local PROGRESS_TOOLS = { + ["subagents.run"] = true, + ["subagents.lua"] = true, + ["subagents.workflow"] = true, +} local GLYPHS = { running = "◷", @@ -36,99 +31,162 @@ local GLYPHS = { } 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" }) +local tools_collapsed = true +local sid_width = 8 +local replay_board +local replaying = true --- --------------------------------------------------------------------------- --- 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]", " ")) + return (tostring(text or ""):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) +local function sanitize_multiline(text) + return (tostring(text or ""):gsub("[%z\1-\9\11-\31\127]", " ")) +end + +local function display_tool_name(name) + return type(name) == "string" and name:gsub("__", ".") or "tool" +end + +local function prefix(text, width) + if width < 1 then return "" end + local count, stop = 0, 0 + local ok = pcall(function() + for pos in utf8.codes(text) do + if count == width then break end + stop = pos + count = count + 1 + end + if count > 0 then stop = (utf8.offset(text, count + 1) or (#text + 1)) - 1 end + end) + if not ok then return text:sub(1, width) end + return text:sub(1, stop) end --- --------------------------------------------------------------------------- --- Cards --- --------------------------------------------------------------------------- +local function wrap(text, width, emit) + width = math.max(1, width) + text = sanitize(text) + if text == "" then emit(""); return end + local rest = text + while rest ~= "" do + local piece = prefix(rest, width) + if piece == "" then piece = rest:sub(1, 1) end + emit(piece) + rest = rest:sub(#piece + 1) + end +end 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) + if board and board.handle then pcall(board.handle.invalidate, board.handle) end +end + +local function trim(card) + while #card.history > MAX_HISTORY_LINES or card.history_bytes > MAX_HISTORY_BYTES do + local removed = table.remove(card.history, 1) + card.history_bytes = card.history_bytes - #removed 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 + line = sanitize(line):sub(1, MAX_LINE_BYTES) + card.history[#card.history + 1] = line + card.history_bytes = card.history_bytes + #line + trim(card) end --- A discrete, already-complete line: a tool marker, a model label, an error. -local function marker(card, prefix, text) +local function marker(card, text) card.partial = false - if text == nil or text == "" then - return - end - adopt(card, prefix .. sanitize(text:sub(1, MAX_LINE_BYTES))) + if text ~= nil and text ~= "" then adopt(card, text) end 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))) + text = tostring(text or "") + local from = 1 + while from <= #text do + local newline = text:find("\n", from, true) + local last = newline and newline - 1 or #text + if card.partial and #card.history > 0 then + local index = #card.history + local old = card.history[index] + local room = MAX_LINE_BYTES - #old + local chunk = room > 0 and text:sub(from, math.min(last, from + room - 1)) or "" + local grown = old .. sanitize(chunk) + card.history[index] = grown + card.history_bytes = card.history_bytes + #grown - #old + trim(card) + else + adopt(card, text:sub(from, math.min(last, from + MAX_LINE_BYTES - 1))) end card.partial = newline == nil - rest = newline and rest:sub(newline + 1) or "" + if not newline then break end + from = newline + 1 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 +local function append_labeled(card, label, text) + text = tostring(text or "") + local continuation = string.rep(" ", #label) + local from, first = 1, true + while true do + local newline = text:find("\n", from, true) + local last = newline and newline - 1 or #text + local prefix = first and label or continuation + local room = math.max(0, MAX_LINE_BYTES - #prefix) + local line = room > 0 and text:sub(from, math.min(last, from + room - 1)) or "" + adopt(card, prefix .. line) + first = false + if not newline then break end + from = newline + 1 end - local kind = event.type +end + +function card_mt:event(event) + if type(event) ~= "table" then return end + local kind, index = event.type, event.index if kind == "block_start" then - self.text_block = event.block_type == "text" + self.blocks[index] = event.block_type + self.block_had_delta[index] = false elseif kind == "content_delta" then - if self.text_block and type(event.delta) == "string" then + if self.blocks[index] == "text" and type(event.delta) == "string" then append_text(self, event.delta) + self.block_had_delta[index] = true end elseif kind == "tool_details" then - marker(self, "⚒ ", event.name) - elseif kind == "tool_dispatch_complete" or kind == "message_complete" then + self.tools[index] = { id = event.id, name = event.name } + local detail = display_tool_name(event.name) + if event.id and event.id ~= "" then detail = detail .. " [" .. event.id .. "]" end + marker(self, "⚒ " .. detail) + elseif kind == "block_complete" then + if event.block_type == "text" and not self.block_had_delta[index] and type(event.text) == "string" then + append_text(self, event.text) + elseif event.block_type == "tool_use" then + if not self.tools[index] then + local detail = display_tool_name(event.name) + if event.id and event.id ~= "" then detail = detail .. " [" .. event.id .. "]" end + marker(self, "⚒ " .. detail) + end + if type(event.text) == "string" then append_labeled(self, "input: ", event.text) end + end + self.blocks[index], self.block_had_delta[index] = nil, nil + self.partial = false + elseif kind == "tool_dispatch_result" then + for _, result in ipairs(type(event.tool_results) == "table" and event.tool_results or {}) do + if type(result) == "table" then + local label = result.is_error and "error" or "result" + if type(result.tool_use_id) == "string" and result.tool_use_id ~= "" then + label = label .. " [" .. result.tool_use_id .. "]" + end + append_labeled(self, label .. ": ", result.output) + end + end + self.partial = false + elseif kind == "message_complete" or kind == "tool_dispatch_complete" then self.partial = false else return @@ -136,123 +194,533 @@ function card_mt:event(event) 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 + 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, - }, -}) +local NOOP = setmetatable({ history = {} }, { __index = { event = function() end, done = function() end } }) --- --------------------------------------------------------------------------- --- Boards (one per tool-call entry) --- --------------------------------------------------------------------------- +local function register_sid(sid) + if sid == "" then return end + for _, board in pairs(boards) do + for _, card in ipairs(board.cards) do + local other = card.sid + if other ~= "" and other ~= sid then + local limit, common = math.min(#sid, #other), 0 + while common < limit and sid:byte(common + 1) == other:byte(common + 1) do + common = common + 1 + end + sid_width = math.max(sid_width, math.min(common + 1, math.max(#sid, #other))) + end + end + end +end + +local function body_lines(card, width, collapsed) + local out, total = {}, 0 + local function add(line) + total = total + 1 + if collapsed and #out == COLLAPSED_LINES then table.remove(out, 1) end + if #out < MAX_RENDER_LINES then out[#out + 1] = BODY_INDENT .. line end + end + if not collapsed then + local metadata_truncated = false + local function add_metadata(label, text) + local continuation = string.rep(" ", #label) + local from, first, lines = 1, true, 0 + local function add_bounded(line) + if lines < MAX_PRESENTATION_LINES then + lines = lines + 1 + add(line) + else + metadata_truncated = true + end + end + while true do + local newline = text:find("\n", from, true) + local last = newline and newline - 1 or #text + wrap((first and label or continuation) .. text:sub(from, last), width, add_bounded) + first = false + if not newline then break end + from = newline + 1 + end + end + if card.system_prompt then add_metadata("system prompt: ", card.system_prompt) end + if card.prompt then add_metadata("prompt: ", card.prompt) end + if metadata_truncated then add("… prompt display truncated") end + end + for _, line in ipairs(card.history) do wrap(line, width, add) end + if not collapsed and total > #out then + out[#out] = BODY_INDENT .. "… retained history exceeds render limit" + end + return out +end local function render(board, width) local out = {} - if #board.cards == 0 or (width or 0) <= 4 then - return out - end + width = math.floor(tonumber(width) or 0) + if #board.cards == 0 or width <= #BODY_INDENT 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 + local sid = card.sid ~= "" and (" " .. card.sid:sub(1, sid_width) .. + (sid_width < #card.sid and "…" or "")) or "" + out[#out + 1] = prefix(sanitize(string.format("%s %s%s — %s", + GLYPHS[card.status] or GLYPHS.running, card.label, sid, card.status)), width) + local lines = body_lines(card, width - #BODY_INDENT, board.collapsed) + for _, line in ipairs(lines) do out[#out + 1] = line end end + if board.pinned then out[#out + 1] = "" end return out end +local function set_board_pinned(board, pinned) + if board.pinned == pinned or not board.handle then return end + local ok = pcall(function() board.handle:set_pinned(pinned) end) + if ok then + board.pinned = pinned + pcall(board.handle.invalidate, board.handle) + end +end + +local function prune_boards() + for key, board in pairs(boards) do + if board.handle and board.handle.alive then + local ok, alive = pcall(board.handle.alive, board.handle) + if ok and not alive then boards[key] = nil end + end + end +end + local function new_board() - local board = { cards = {}, handle = nil } - board.component = { - render = function(_, width) - return render(board, width) - end, - } + local board = { cards = {}, handle = nil, collapsed = tools_collapsed, next_sequence = 0, pinned = false } + 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; an unknown field answers nil rather than - -- raising, and activation already refused a host too old to have these. + prune_boards() 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 - return - end - - local key = event.id or name - local board = new_board() + if not PROGRESS_TOOLS[name] then return end + if type(event.set_component) ~= "function" then return end + if type(event.collapsed) == "boolean" then tools_collapsed = event.collapsed end + local key, board = event.id or event.tool_call_id or name, new_board() local attached, handle = pcall(event.set_component, event, board.component) - if not attached then - return - end + if not attached then return end board.handle = handle + local prior = boards[key] + if prior then set_board_pinned(prior, false) end boards[key] = board + set_board_pinned(board, true) + -- During startup/restart the child catalog already contains the durable + -- turns for this outer call. A live call normally finds nothing here; the + -- ownership stamp makes that distinction without parsing outer results. + if replaying then + replay_board(board, type(key) == "string" and key or nil) + end +end + +-- A result settles the outer tool invocation. Keep the board attached to its +-- transcript entry, but return it to that entry's original ordering. +function M.settle(event) + local key = type(event) == "table" and (event.id or event.tool_call_id) or nil + local board = key and boards[key] + if not board then return end + set_board_pinned(board, false) +end + +function M.collapse(event) + if type(event.collapsed) ~= "boolean" then return end + prune_boards() + tools_collapsed = event.collapsed + for _, board in pairs(boards) do + board.collapsed = tools_collapsed + if board.handle then pcall(board.handle.invalidate, board.handle) end + end 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 + if type(key) ~= "string" or key == "" then key = nil end + local co = coroutine.running() + if co then bound[co] = key end 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 +-- The outer tool call is durable child-turn metadata, not model-visible output. +-- Expose it to the spawn seam without making the board state global: workflow +-- callbacks run on the same coroutine as their owning tool handler. +function M.tool_call_id() + local co = coroutine.running() + return co and bound[co] or nil +end +local function new_card(board, label, id, detail, presentation, sequence) + if board == nil then return NOOP end + local sid = type(id) == "string" and sanitize(id):sub(1, MAX_LINE_BYTES) or "" + register_sid(sid) + if type(sequence) ~= "number" or sequence < 1 or sequence % 1 ~= 0 then + board.next_sequence = board.next_sequence + 1 + sequence = board.next_sequence + else + board.next_sequence = math.max(board.next_sequence, sequence) + end local card = setmetatable({ board = board, - label = tostring(label or "subagent"), - sid = type(id) == "string" and id:sub(1, 8) or "", + label = sanitize(label or "subagent"):sub(1, MAX_LINE_BYTES), + sid = sid, + sequence = sequence, status = "running", - lines = {}, + history = {}, + history_bytes = 0, partial = false, - text_block = false, + blocks = {}, + block_had_delta = {}, + tools = {}, + prompt = type(presentation) == "table" and type(presentation.prompt) == "string" and + sanitize_multiline(presentation.prompt):sub(1, MAX_PRESENTATION_BYTES) or nil, + system_prompt = type(presentation) == "table" and type(presentation.system_prompt) == "string" and + sanitize_multiline(presentation.system_prompt):sub(1, MAX_PRESENTATION_BYTES) or nil, }, card_mt) board.cards[#board.cards + 1] = card - marker(card, "↳ ", detail) + if detail then marker(card, "↳ " .. sanitize(detail):sub(1, MAX_LINE_BYTES)) end 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.card(label, id, detail, presentation) + 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 + return new_card(board, label, id, detail, presentation) +end + +local function nonempty(value) + return type(value) == "string" and value ~= "" and value or nil +end + +-- Keep replay work finite even when a damaged child file contains pathological +-- payloads. The card itself has stricter history limits; this cap bounds the +-- temporary strings made while translating stored blocks into progress events. +local function bounded_text(value, limit) + if type(value) ~= "string" then return "" end + return #value > limit and value:sub(1, limit) or value +end + +local function text_blocks(blocks, limit) + if type(blocks) ~= "table" then return "" end + local pieces, bytes, seen = {}, 0, 0 + for _, block in ipairs(blocks) do + seen = seen + 1 + if seen > MAX_REPLAY_BLOCKS then break end + if type(block) == "table" and (block.type == "text" or block.type == "system") and type(block.text) == "string" then + if #pieces > 0 and bytes < limit then + pieces[#pieces + 1] = "\n" + bytes = bytes + 1 + end + local room = limit - bytes + if room <= 0 then break end + local text = block.text:sub(1, room) + pieces[#pieces + 1] = text + bytes = bytes + #text + if #text < #block.text then break end + end + end + return table.concat(pieces) +end + +local function blocks_of(message) + return type(message) == "table" and type(message.blocks) == "table" and message.blocks or {} +end + +local function message_text(message) + return type(message) == "table" and text_blocks(blocks_of(message), MAX_REPLAY_BLOCK_BYTES) or "" +end + +local function metadata_for(conv, index) + if conv == nil or type(conv.message_metadata) ~= "function" then return nil end + local ok, metadata = pcall(conv.message_metadata, conv, index) + if not ok or type(metadata) ~= "table" or type(metadata.subagents) ~= "table" then + return nil + end + return metadata.subagents +end + +local function owner_of(metadata) + if type(metadata) ~= "table" then return nil end + return nonempty(metadata.tool_call_id) +end + +local function child_manifest(conv, messages) + for index = 1, #messages do + local message = messages[index] + if type(message) == "table" and message.role == "system" then + local metadata = metadata_for(conv, index) + local agent = metadata and nonempty(metadata.agent) + if agent then + local system_prompt + if metadata.inline == true then + system_prompt = nonempty(message_text(message)) + end + return agent, system_prompt + end + end + end + return nil, nil +end + +local function result_text(block) + if type(block) ~= "table" then return "" end + local parts = block.parts or block.content + if type(parts) ~= "table" then return "" end + local pieces, bytes, seen = {}, 0, 0 + for _, part in ipairs(parts) do + seen = seen + 1 + if seen > MAX_REPLAY_BLOCKS then break end + if type(part) == "table" and type(part.text) == "string" then + local room = MAX_REPLAY_BLOCK_BYTES - bytes + if room <= 0 then break end + local text = part.text:sub(1, room) + pieces[#pieces + 1] = text + bytes = bytes + #text + if #text < #part.text then break end + end + end + return table.concat(pieces, "\n") +end + +local function is_prompt_message(message) + return type(message) == "table" and message.role == "user" and message_text(message) ~= "" +end + +-- Translate one stored child turn through the same card event path used by a +-- live stream. No outer tool result is involved: all displayed content comes +-- from this child's durable conversation. +local function replay_turn(board, conv, messages, start, finish, info, agent, system_prompt, sequence) + local metadata = metadata_for(conv, start) + local prompt = message_text(messages[start]) + if prompt == "" then return end + + local id = nonempty(info and info.id) + local model = nonempty(metadata and metadata.model) or nonempty(info and info.model) + local card = new_card(board, agent or "subagent", id, model, { + prompt = prompt, + system_prompt = system_prompt, + }, sequence) + local pending, terminal_assistant = {}, false + local block_index = 0 + + for index = start + 1, finish - 1 do + local message = messages[index] + if type(message) == "table" and message.role == "assistant" then + local has_tool = false + local has_response = false + local seen_blocks = 0 + for _, block in ipairs(blocks_of(message)) do + seen_blocks = seen_blocks + 1 + if seen_blocks > MAX_REPLAY_BLOCKS then break end + if type(block) == "table" and block.type == "tool_use" then + has_tool = true + local tool_id = bounded_text(block.id, MAX_REPLAY_BLOCK_BYTES) + local tool_name = bounded_text(block.name, MAX_REPLAY_BLOCK_BYTES) + local input = bounded_text(block.input, MAX_REPLAY_BLOCK_BYTES) + if tool_id ~= "" then pending[tool_id] = true end + card:event({ type = "block_complete", block_type = "tool_use", + index = block_index, id = tool_id, name = tool_name, text = input }) + block_index = block_index + 1 + else + -- Thinking-only and other non-text assistant messages are + -- still real completed responses; presentation text is + -- optional and must not decide terminal status. + has_response = true + if type(block) == "table" and block.type == "text" then + local text = bounded_text(block.text, MAX_REPLAY_BLOCK_BYTES) + if text ~= "" then + card:event({ type = "block_complete", block_type = "text", + index = block_index, text = text }) + end + block_index = block_index + 1 + end + end + end + -- A response containing a tool call is not the settled assistant + -- answer; a later assistant message after its results is. + terminal_assistant = has_response and not has_tool + elseif type(message) == "table" and message.role == "user" then + local results = {} + local seen_blocks = 0 + for _, block in ipairs(blocks_of(message)) do + seen_blocks = seen_blocks + 1 + if seen_blocks > MAX_REPLAY_BLOCKS then break end + if type(block) == "table" and block.type == "tool_result" then + local tool_id = bounded_text(block.tool_use_id, MAX_REPLAY_BLOCK_BYTES) + if tool_id ~= "" then pending[tool_id] = nil end + results[#results + 1] = { + tool_use_id = tool_id, + output = result_text(block), + is_error = block.is_error == true, + } + end + end + if #results > 0 then + card:event({ type = "tool_dispatch_result", tool_results = results }) + end + end + end + + local status = metadata and nonempty(metadata.status) + if status ~= "completed" and status ~= "failed" and status ~= "cancelled" then + status = nil + end + if status == nil then + local settled = terminal_assistant + if settled then + for _ in pairs(pending) do + settled = false + break + end + end + status = settled and "completed" or "failed" + end + card:done(status, status == "failed" and "child turn has no settled assistant response" or nil) + card.replay_sequence = sequence + return card +end + +local function replay_sequence(metadata) + local sequence = type(metadata) == "table" and tonumber(metadata.sequence) or nil + if sequence == nil or sequence < 1 or sequence % 1 ~= 0 then return nil end + return sequence +end + +replay_board = function(board, tool_call_id) + if board == nil or type(tool_call_id) ~= "string" or tool_call_id == "" then return end + + local path_ok, store_dir = pcall(paths.child_store_dir) + if not path_ok or type(store_dir) ~= "string" or store_dir == "" then return end + local panto_ok, panto = pcall(require, "panto") + if not panto_ok or type(panto) ~= "table" or type(panto.file_system_jsonl_store) ~= "function" then return end + local store_ok, store = pcall(panto.file_system_jsonl_store, { dir = store_dir }) + if not store_ok or store == nil or type(store.list_bounded) ~= "function" or + type(store.load_messages) ~= "function" then return end + + -- The store owns both bounds. In particular, this never asks the binding + -- for an unbounded catalog or conversation and trims only after a backend + -- has already applied the requested limit. + local listed, infos = pcall(store.list_bounded, store, { limit = MAX_REPLAY_SESSIONS }) + if not listed or type(infos) ~= "table" then return end + local sessions = {} + for _, info in ipairs(infos) do + if type(info) == "table" and nonempty(info.id) then sessions[#sessions + 1] = info end + end + table.sort(sessions, function(a, b) + local ac, bc = nonempty(a.created), nonempty(b.created) + if ac and bc and ac ~= bc then return ac < bc end + return a.id < b.id + end) + + -- Translate each bounded session into bounded card history immediately; + -- retaining whole conversations for all sessions would merely move the + -- unbounded replay footprint from the store to Lua while sorting. + local scratch = new_board() + local cards = {} + local ordinal = 0 + for _, info in ipairs(sessions) do + if #cards >= MAX_REPLAY_CARDS then break end + local loaded, conv = pcall(store.load_messages, store, info.id, { + limit = MAX_REPLAY_MESSAGES, + from_end = true, + max_bytes = MAX_REPLAY_MESSAGE_BYTES, + }) + if loaded and conv ~= nil and type(conv.messages) == "function" then + local messages_ok, messages = pcall(conv.messages, conv) + if messages_ok and type(messages) == "table" and #messages > 0 then + local turns = {} + local index = 1 + while index <= #messages do + if is_prompt_message(messages[index]) then + local finish = index + 1 + while finish <= #messages and not is_prompt_message(messages[finish]) do + finish = finish + 1 + end + local metadata = metadata_for(conv, index) + if owner_of(metadata) == tool_call_id then + turns[#turns + 1] = { + start = index, + finish = finish, + metadata = metadata, + sequence = replay_sequence(metadata), + } + end + index = finish + else + index = index + 1 + end + end + + if #turns > 0 then + local manifest_ok, manifest_conv = pcall(store.load_messages, store, info.id, { + limit = 1, + role = "system", + metadata_key = "subagents", + max_bytes = MAX_REPLAY_MESSAGE_BYTES, + }) + local agent, system_prompt + if manifest_ok and manifest_conv ~= nil and type(manifest_conv.messages) == "function" then + local got_messages, manifest_messages = pcall(manifest_conv.messages, manifest_conv) + if got_messages and type(manifest_messages) == "table" then + local got_manifest + got_manifest, agent, system_prompt = pcall(child_manifest, manifest_conv, manifest_messages) + if not got_manifest then agent, system_prompt = nil, nil end + end + end + for _, turn in ipairs(turns) do + if #cards >= MAX_REPLAY_CARDS then break end + ordinal = ordinal + 1 + local card_ok, card = pcall(replay_turn, scratch, conv, messages, + turn.start, turn.finish, info, agent, system_prompt, turn.sequence) + if card_ok and card ~= nil then + card.replay_ordinal = ordinal + cards[#cards + 1] = card + end + end + end + end + end + end + + table.sort(cards, function(a, b) + if a.replay_sequence and b.replay_sequence and a.replay_sequence ~= b.replay_sequence then + return a.replay_sequence < b.replay_sequence + end + if (a.replay_sequence ~= nil) ~= (b.replay_sequence ~= nil) then + return a.replay_sequence ~= nil + end + return a.replay_ordinal < b.replay_ordinal + end) + for _, card in ipairs(cards) do + if #board.cards >= MAX_REPLAY_CARDS then break end + card.board = board + board.cards[#board.cards + 1] = card + register_sid(card.sid) + repaint(card) + end +end + +-- The host fires this before the first live model turn, after startup +-- conversation replay has finished. Keeping the mode explicit avoids scanning +-- child files for every ordinary foreground call. +function M.begin_live_turn() + replaying = false +end + function M.reset() - boards = {} + for _, board in pairs(boards) do + set_board_pinned(board, false) + end + prune_boards() bound = setmetatable({}, { __mode = "k" }) end return M + diff --git a/subagents/spawn.lua b/subagents/spawn.lua index b355d9a..caf65aa 100644 --- a/subagents/spawn.lua +++ b/subagents/spawn.lua @@ -18,9 +18,13 @@ -- context, then the fixed child-role instruction, then — when the profile has -- a body — the profile prompt as a further system message. That profile -- message carries the immutable manifest metadata (owning primary session id + --- profile name), which is how a resumed child re-identifies itself. The parent --- dialogue is never copied, which is why the child-role text tells the child --- its final message is the whole of what the delegator sees. +-- profile name); workflow-local profiles also carry an inline marker so the +-- progress replay path can expose only prompts the caller explicitly supplied. +-- The per-turn user metadata records the effective model/reasoning, the +-- per-outer-call card sequence, terminal presentation status, and — when the +-- spawn happened inside a bound extension tool — that outer tool call id. +-- The parent dialogue is never copied, which is why the child-role text tells +-- the child its final message is the whole of what the delegator sees. -- -- Edge cases: a profile with an empty body contributes no system message and -- therefore no manifest, so a resumed child started from a body-less profile @@ -206,12 +210,17 @@ function M.build_spec(input, profiles) local system_messages = { { text = M.CHILD_ROLE } } if profile.body and profile.body:match("%S") then + local manifest = { owner = info_or_err.session_id, agent = profile.name } + if profile.layer == "workflow" then manifest.inline = true end system_messages[#system_messages + 1] = { text = profile.body, - metadata = { subagents = { owner = info_or_err.session_id, agent = profile.name } }, + metadata = { subagents = manifest }, } end spec.system_messages = system_messages + if profile.layer == "workflow" then + spec.presentation_system_prompt = profile.body + end return spec end @@ -447,10 +456,31 @@ function M.spawn(spec) local named, session_id = try(agent.session_id, agent) local id = (not one_shot) and named and nonempty(session_id) or nil - local card = progress.card(spec.label or "subagent", id, model_label) + local turn_index + local got_conv, child_conv = try(agent.conversation, agent) + if got_conv and child_conv and type(child_conv.len) == "function" then + local got_len, length = try(child_conv.len, child_conv) + if got_len and type(length) == "number" then turn_index = length + 1 end + end + + local card = progress.card(spec.label or "subagent", id, model_label, { + prompt = spec.prompt, + system_prompt = spec.presentation_system_prompt, + }) + + local owner_tool_call_id = progress.tool_call_id() + local turn_metadata = { + subagents = { model = model_label, reasoning = reasoning_label }, + } + local sequence = card.sequence + if type(sequence) == "number" then turn_metadata.subagents.sequence = sequence end + if type(owner_tool_call_id) == "string" and owner_tool_call_id ~= "" then + turn_metadata.subagents.tool_call_id = owner_tool_call_id + end -- The settled run_async result becomes the result table every caller -- already reads: run.lua's block and the workflow API's shape_result. + local child_handle local function shape(raw) raw = type(raw) == "table" and raw or {} local result = { @@ -483,16 +513,30 @@ function M.spawn(spec) local ok, found = try(store.resolve, store, id) result.resumable = ok and found ~= nil end + turn_metadata.subagents.status = result.status + -- `agent:set_message_metadata` is deliberately refused while a pump + -- is live. The result has already been copied out, so joining here is + -- safe and releases the agent's mutation guard before the annotation. + if child_handle and child_handle.job then + pcall(child_handle.job.close, child_handle.job) + end + if turn_index and type(agent.set_message_metadata) == "function" then + -- The binding's annotation seam updates the already-written user + -- record, so cancellation has a durable presentation status even + -- though the stream correctly rolls its assistant messages back. + pcall(agent.set_message_metadata, agent, turn_index, turn_metadata) + end card:done(result.status, result.error) return result end - local handle, start_err = jobs.start { + local start_err + child_handle, start_err = jobs.start { id = id, build = function(wake_fd) local job, err = agent:run_async { prompt = spec.prompt, - metadata = { subagents = { model = model_label, reasoning = reasoning_label } }, + metadata = turn_metadata, dispatch_tools = not one_shot, wake_fd = wake_fd, } @@ -506,6 +550,7 @@ function M.spawn(spec) end, shape = shape, } + local handle = child_handle if not handle then card:done("failed", start_err) return nil, start_err |
