summaryrefslogtreecommitdiff
path: root/subagents/luatool.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/luatool.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/luatool.lua')
-rw-r--r--subagents/luatool.lua193
1 files changed, 193 insertions, 0 deletions
diff --git a/subagents/luatool.lua b/subagents/luatool.lua
new file mode 100644
index 0000000..089c67b
--- /dev/null
+++ b/subagents/luatool.lua
@@ -0,0 +1,193 @@
+-- subagents/luatool.lua
+--
+-- The `subagents.lua` model-facing tool: run a transient, model-authored Lua
+-- 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.
+--
+-- 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
+-- standard library plus `subagents.workflow`; it has no `os`, `io`, `debug`,
+-- `package`, `require`, `load`, `dofile`, `coroutine`, `setmetatable`, or
+-- `getmetatable`, and `print` is a no-op so generated code cannot scribble on
+-- the TUI. `string`, `table`, `math`, and `utf8` are shallow copies, so a guest
+-- that reassigns `table.insert` only breaks itself, and `string.dump` is
+-- removed from the copy.
+--
+-- `pcall`/`xpcall` are deliberately absent: they would let a guest catch the
+-- instruction-budget error and spin again in fresh 10M-instruction chunks. A
+-- guest has no need to recover from its own errors — the handler reports them —
+-- and with no `pcall`, `coroutine`, or metatable access left, nothing in the
+-- environment can trap the hook error before it reaches the host.
+--
+-- Known, accepted gaps in that sandbox:
+--
+-- * The real string metatable is still reachable through any string literal
+-- (`("").dump`), so `string.dump` is obtainable. Without `load` there is no
+-- way to turn bytecode back into a running function, so this is noise rather
+-- than an escape.
+-- * `ctx:agent` returns handles the guest can read and scribble on. Nothing
+-- reachable from one starts work: the running job — which owns the child's
+-- agent and could start turns outside the cap — lives in a private side table
+-- in workflow.lua, as do the job cap, the counter, and the profile set, so a
+-- guest holding `ctx` and its handles cannot raise its own cap or reach the
+-- host.
+--
+-- Runaway generated Lua is bounded two ways: `max_jobs = 32` caps how many
+-- children one transient workflow may start, and a debug count hook is armed
+-- for the duration of the guest callback and disarmed as soon as it returns.
+-- The hook lives here rather than in workflow.lua because the guest has no
+-- `debug` library but the host does; workflow.execute only exposes the
+-- on_resume/on_yield seam the hook needs. The guest runs on the tool handler's
+-- own coroutine (an await parks and resumes exactly that coroutine when a child
+-- settles), so the budget covers the whole run rather than one slice; awaiting
+-- a child executes no instructions, so only real spinning trips it.
+
+local workflow = require("subagents.workflow")
+local run = require("subagents.run")
+
+local MAX_JOBS = 32
+local INSTRUCTION_BUDGET = 10000000
+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 = {}
+ for key, value in pairs(source) do
+ if key ~= skip then
+ copy[key] = value
+ end
+ end
+ return copy
+end
+
+-- Build a fresh restricted environment per call: the guest may mutate anything
+-- it can reach, so nothing here is shared between invocations.
+local function build_env()
+ local env = {
+ assert = assert,
+ error = error,
+ ipairs = ipairs,
+ next = next,
+ pairs = pairs,
+ select = select,
+ tonumber = tonumber,
+ tostring = tostring,
+ type = type,
+ string = shallow_copy(string, "dump"),
+ table = shallow_copy(table),
+ math = shallow_copy(math),
+ utf8 = shallow_copy(utf8),
+ print = function() end,
+ subagents = { workflow = workflow.workflow },
+ }
+ env._G = env
+ return env
+end
+
+M.build_env = build_env
+
+local function budget_hook()
+ error("instruction budget exceeded", 0)
+end
+
+-- subagents.run's block formatter expects string output; a structured worker's
+-- output is a decoded table, so it is re-encoded first.
+local function format_one(result)
+ if type(result.output) == "table" then
+ local flattened = {}
+ for key, value in pairs(result) do
+ flattened[key] = value
+ end
+ flattened.output = workflow.output_text(result)
+ return run.format_result(flattened)
+ end
+ return run.format_result(result)
+end
+
+-- Format whatever the workflow callback returned. Result-shaped tables (the
+-- common case: one settled result, or an array of them) render as the same
+-- plain "key: value" block subagents.run uses; anything else is encoded
+-- compactly so the model still sees it.
+local function format_return(value)
+ if value == nil then
+ return "The workflow returned no value."
+ end
+ if type(value) ~= "table" then
+ return tostring(value)
+ end
+ if value.status ~= nil then
+ return format_one(value)
+ end
+ local blocks, count = {}, 0
+ for index, entry in ipairs(value) do
+ if type(entry) ~= "table" or entry.status == nil then
+ blocks = nil
+ break
+ end
+ blocks[index] = format_one(entry)
+ count = count + 1
+ end
+ if blocks and count > 0 then
+ return table.concat(blocks, "\n\n")
+ end
+ 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)
+ if type(input) ~= "table" then
+ return "Error: expected an input object"
+ end
+ if type(input.prompt) ~= "string" or input.prompt == "" then
+ return "Error: prompt is required and must be a non-empty string"
+ end
+ if type(input.source) ~= "string" or input.source == "" then
+ return "Error: source is required and must be a non-empty string"
+ 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)
+ end
+
+ local built_ok, built = pcall(chunk)
+ if not built_ok then
+ return "Error: source failed to run: " .. tostring(built)
+ end
+ if not workflow.is_workflow(built) then
+ return "Error: source must return subagents.workflow(function(ctx, input) ... end)"
+ end
+
+ local armed = nil
+ local ran_ok, result = pcall(workflow.execute, built, input.prompt, {
+ max_jobs = MAX_JOBS,
+ profiles = profiles,
+ on_resume = function(co)
+ armed = co
+ debug.sethook(co, budget_hook, "", INSTRUCTION_BUDGET)
+ end,
+ on_yield = function(co)
+ debug.sethook(co)
+ armed = nil
+ end,
+ })
+ if armed then
+ debug.sethook(armed)
+ end
+
+ if not ran_ok then
+ return "Error: " .. tostring(result)
+ end
+ return format_return(result)
+end
+
+return M