diff options
| author | t <t@tjp.lol> | 2026-08-20 15:30:59 -0600 |
|---|---|---|
| committer | t <t@tjp.lol> | 2026-08-20 15:32:46 -0600 |
| commit | 4183fdc7d3667fb4c2af72ff0098c15168c9e950 (patch) | |
| tree | 8bf9aecc4e21966ae6854ce8aeeed92078363724 | |
| parent | b7a1f0d934cab519613e8ee2a08f052e0d56687e (diff) | |
Expose a json codec to the subagents.lua sandbox
Model-authored workflow Lua could reach string, table, math, and utf8 but
had no way to parse an agent's report, so any fan-out over discovered work
had to invent an ad-hoc line format and hand-roll string.gmatch. Hand the
guest panto.ext.json instead. decode returns nil plus a message rather
than raising: the sandbox deliberately has no pcall, so a raising decoder
would let one malformed agent line kill an entire workflow.
Teach the pattern in the injected guidance too: deterministic Lua owns the
loops, counts, ordering, and gates, agents supply discovery and judgment,
and a worker that feeds Lua ends its report with one parseable line. The
existing static-graph example stays; a discover-then-fan-out example joins
it.
| -rw-r--r-- | init.lua | 29 | ||||
| -rw-r--r-- | spec/test_luatool.lua | 13 | ||||
| -rw-r--r-- | subagents/luatool.lua | 26 | ||||
| -rw-r--r-- | subagents/workflow.lua | 1 |
4 files changed, 68 insertions, 1 deletions
@@ -174,6 +174,35 @@ return subagents.workflow(function(ctx) end) ``` +**Deterministic code drives; agents advise.** A workflow is code you control wrapped around judgment you cannot. Put every decision that can be nailed down into Lua — loops, counts, ordering, retries, pass/fail gates — and spend agents only on discovery and judgment. When Lua needs a fact an agent found (a list to fan out over, a verdict, a chosen option), tell that agent to end its report with one machine-parseable line, parse it, and let the parsed value shape the rest of the graph. The sandbox has `json.encode(value)` and `json.decode(text)`, which returns the value or `nil, message`. + +```lua +return subagents.workflow(function(ctx) + local found = ctx:agent{ + name="inventory", + system_prompt="Inventory work items. Read-only.", + prompt="List every open PR in owner/repo. End with exactly one line: RESULT={\"prs\":[<numbers>]}", + }:await() + + local line = found.output and found.output:match("RESULT=([^\n]+)") + local items = line and json.decode(line) + if not items or not items.prs then return "inventory produced no parseable RESULT line" end + + local reviewers = {} + for _, pr in ipairs(items.prs) do + reviewers[#reviewers + 1] = ctx:agent{ + name = "review-" .. pr, + system_prompt = "Review one pull request.", + prompt = "Review PR #" .. pr .. " in owner/repo.", + } + end + + local reviews, out = ctx:await(reviewers, "all"), {} + for _, review in ipairs(reviews) do out[#out + 1] = review.output end + return table.concat(out, "\n\n") +end) +``` + Every `ctx:agent` needs a non-empty `name` unique within that workflow. A workflow callback must return a string. Inspect a running or finished workflow with a later `subagents.lua` call, for example: ```lua diff --git a/spec/test_luatool.lua b/spec/test_luatool.lua index d002bca..62f9283 100644 --- a/spec/test_luatool.lua +++ b/spec/test_luatool.lua @@ -57,6 +57,19 @@ return { assert(env.print() == nil) end }, + { "the guest json codec round-trips and reports bad input instead of raising", function() + local env = luatool.build_env() + local encoded = env.json.encode({ prs = { 124, 125 } }) + has(encoded, "124") + local decoded = env.json.decode(encoded) + assert(type(decoded) == "table" and decoded.prs[2] == 125) + -- The sandbox has no pcall, so a raising decoder would kill a workflow + -- over one malformed agent line. + local value, message = env.json.decode("RESULT=not json") + assert(value == nil and type(message) == "string") + assert(env.json.decode(nil) == nil) + end }, + { "the string metatable stays reachable and harmless", function() local env = luatool.build_env() assert(type(("").dump) == "function") diff --git a/subagents/luatool.lua b/subagents/luatool.lua index dee7398..fced6b1 100644 --- a/subagents/luatool.lua +++ b/subagents/luatool.lua @@ -10,7 +10,8 @@ -- -- 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`, +-- standard library, a `json` codec, and `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 @@ -63,6 +64,27 @@ local function shallow_copy(source, skip) return copy end +-- JSON for the guest. `decode` returns `nil, message` instead of raising: +-- the sandbox has no `pcall`, so a raising decoder would make one malformed +-- agent line kill the whole workflow. +local function guest_json() + return { + decode = function(text) + if type(text) ~= "string" then + return nil, "expected a string" + end + local ok, value = pcall(workflow.json_decode, text) + if not ok then + return nil, tostring(value) + end + return value + end, + encode = function(value) + return workflow.json_encode(value) + end, + } +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(schedule) @@ -82,6 +104,7 @@ local function build_env(schedule) math = shallow_copy(math), utf8 = shallow_copy(utf8), print = function() end, + json = guest_json(), subagents = { workflow = schedule, workflows = workflow.workflows, @@ -92,6 +115,7 @@ local function build_env(schedule) end M.build_env = build_env +M.guest_json = guest_json -- Overlay workflow-local profiles without mutating the activation-time set. -- Inline names intentionally win, matching normal layered profile precedence diff --git a/subagents/workflow.lua b/subagents/workflow.lua index 3a59a8b..375285f 100644 --- a/subagents/workflow.lua +++ b/subagents/workflow.lua @@ -155,6 +155,7 @@ local function json_encode(value) end M.json_encode = json_encode +M.json_decode = json_decode -- --------------------------------------------------------------------------- -- Schema validation |
