diff options
| author | t <t@tjp.lol> | 2026-08-16 20:42:43 -0600 |
|---|---|---|
| committer | t <t@tjp.lol> | 2026-08-17 20:31:29 -0600 |
| commit | 4f0a91ef55fe96835172bdad34feec1e2a0a0977 (patch) | |
| tree | ed4f3e86575aa6243043bc22f8037be153a609c6 /spec/test_luatool.lua | |
| parent | c1ab34754d3f3695fafd344fe1a181ecf0740761 (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 'spec/test_luatool.lua')
| -rw-r--r-- | spec/test_luatool.lua | 188 |
1 files changed, 188 insertions, 0 deletions
diff --git a/spec/test_luatool.lua b/spec/test_luatool.lua new file mode 100644 index 0000000..83b4984 --- /dev/null +++ b/spec/test_luatool.lua @@ -0,0 +1,188 @@ +-- subagents/luatool.lua: the restricted environment, the instruction budget, +-- and one real fan-out through the fake host. +-- +-- The sandbox cases check the environment the guest actually gets rather than +-- only the errors an escape attempt produces, because a missing global is the +-- whole mechanism. One documented gap is asserted as a gap: the real string +-- metatable is reachable from any literal, so `("").dump` exists. Without +-- `load` there is no way to run bytecode, so it stays noise rather than an +-- escape — the assertion is here so a future change to that reasoning is +-- deliberate. + +local fake = require("spec.fake_ext") +local luatool = require("subagents.luatool") + +local function has(text, needle) + assert(type(text) == "string", "expected a string, got " .. type(text)) + assert(text:find(needle, 1, true), "expected to find " .. needle .. " in:\n" .. tostring(text)) +end + +local function profile_set() + local set = { list = {}, by_name = {}, warnings = {} } + for _, name in ipairs({ "alpha", "beta" }) do + local profile = { name = name, description = name, body = "You are " .. name .. ".\n" } + set.list[#set.list + 1] = profile + set.by_name[name] = profile + end + return set +end + +local function with_host(fn) + local handle = fake.install() + local ok, err = pcall(fn, handle, profile_set()) + handle.restore() + if not ok then + error(err, 0) + end +end + +return { + { "the guest environment has no filesystem, process, or module access", function() + local env = luatool.build_env() + for _, name in ipairs({ + "os", "io", "debug", "package", "require", "load", "loadstring", "dofile", + "loadfile", "coroutine", "setmetatable", "getmetatable", "rawset", "rawget", + "collectgarbage", "arg", "pcall", "xpcall", + }) do + assert(env[name] == nil, "the guest can reach " .. name) + end + assert(env._G == env, "_G must point at the restricted table") + assert(type(env.subagents.workflow) == "function", "the workflow constructor is the whole API") + assert(env.string.dump == nil, "string.dump is removed from the guest copy") + assert(env.string ~= string, "the guest gets a copy it may safely mutate") + assert(env.print() == nil, "print is a no-op") + end }, + + { "the string metatable stays reachable, and stays harmless", function() + local env = luatool.build_env() + -- Documented gap: ("").dump resolves through the real string metatable. + assert(type(("").dump) == "function", "the gap this note describes has moved") + assert(env.load == nil and env.loadstring == nil, + "bytecode is only dangerous with a loader, and there is none") + end }, + + { "an escape attempt inside the guest fails at the call", function() + with_host(function(handle, profiles) + local source = [[ + return subagents.workflow(function(ctx, input) + return { status = "completed", output = require("os").time() } + end) + ]] + local text = luatool.handle({ prompt = "x", source = source }, profiles) + has(text, "Error:") + has(text, "nil value") + assert(#handle.spawns == 0, "the guest started no children") + end) + end }, + + { "source that does not return a workflow is refused", function() + with_host(function(handle, profiles) + has(luatool.handle({ prompt = "x", source = "return 42" }, profiles), + "Error: source must return subagents.workflow(function(ctx, input) ... end)") + has(luatool.handle({ prompt = "x", source = "return (" }, profiles), + "Error: source did not compile") + has(luatool.handle({ prompt = "x", source = "error('nope')" }, profiles), + "Error: source failed to run") + end) + end }, + + { "prompt and source are both required", function() + with_host(function(handle, profiles) + has(luatool.handle({ source = "return 1" }, profiles), "Error: prompt is required") + has(luatool.handle({ prompt = "x" }, profiles), "Error: source is required") + has(luatool.handle({ prompt = "", source = "return 1" }, profiles), "Error: prompt is required") + end) + end }, + + { "a runaway guest is stopped by the instruction budget", function() + with_host(function(handle, profiles) + local source = [[ + return subagents.workflow(function(ctx, input) + local n = 0 + while true do n = n + 1 end + end) + ]] + local text = luatool.handle({ prompt = "x", source = source }, profiles) + has(text, "Error:") + has(text, "instruction budget exceeded") + end) + end }, + + { "the guest cannot catch the budget error and spin again", function() + with_host(function(handle, profiles) + -- Bounded so a regression fails this case instead of hanging it: with + -- pcall back in the environment the guest would burn three budgets and + -- then report success. + local source = [[ + return subagents.workflow(function(ctx, input) + for _ = 1, 3 do + pcall(function() while true do end end) + end + return { status = "completed", output = "outlived the budget" } + end) + ]] + local text = luatool.handle({ prompt = "x", source = source }, profiles) + has(text, "Error:") + has(text, "pcall") + end) + end }, + + { "a fan-out runs end to end and renders one block per child", function() + with_host(function(handle, profiles) + handle.queue_for("alpha", { id = "0198-a", output = "alpha says hi" }) + handle.queue_for("beta", { id = "0198-b", output = "beta says hi" }) + + local source = [[ + return subagents.workflow(function(ctx, input) + local jobs = {} + for _, name in ipairs({ "alpha", "beta" }) do + jobs[#jobs + 1] = ctx:agent({ agent = name, prompt = "handle " .. input }) + end + return ctx:await(jobs, "all") + end) + ]] + local text = luatool.handle({ prompt = "the task", source = source }, profiles) + + assert(#handle.spawns == 2, "one spawn per ctx:agent") + assert(handle.spawns[1].prompt == "handle the task", tostring(handle.spawns[1].prompt)) + assert(handle.spawns[1].label == "alpha") + has(text, "id: 0198-a") + has(text, "alpha says hi") + has(text, "id: 0198-b") + has(text, "beta says hi") + assert(select(2, text:gsub("status: completed", "")) == 2, "expected two rendered blocks") + end) + end }, + + { "the job budget applies to a generated workflow", function() + with_host(function(handle, profiles) + local source = string.format([[ + return subagents.workflow(function(ctx, input) + for index = 1, %d do + ctx:agent({ agent = "alpha", prompt = "spam " .. index }) + end + end) + ]], luatool.max_jobs + 1) + local text = luatool.handle({ prompt = "x", source = source }, profiles) + has(text, "job limit exceeded") + assert(#handle.runs == luatool.max_jobs, "the cap is enforced at the host boundary") + end) + end }, + + { "the guest cannot raise its own job cap through ctx", function() + with_host(function(handle, profiles) + local source = string.format([[ + return subagents.workflow(function(ctx, input) + ctx.max_jobs = nil + ctx.job_count = 0 + for index = 1, %d do + ctx:agent({ agent = "alpha", prompt = "spam " .. index }) + end + end) + ]], luatool.max_jobs + 1) + local text = luatool.handle({ prompt = "x", source = source }, profiles) + has(text, "job limit exceeded") + assert(#handle.runs == luatool.max_jobs, "the cap is private state, not a ctx field") + end) + end }, +} |
