summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authort <t@tjp.lol>2026-08-20 15:30:59 -0600
committert <t@tjp.lol>2026-08-20 15:32:46 -0600
commit4183fdc7d3667fb4c2af72ff0098c15168c9e950 (patch)
tree8bf9aecc4e21966ae6854ce8aeeed92078363724
parentb7a1f0d934cab519613e8ee2a08f052e0d56687e (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.lua29
-rw-r--r--spec/test_luatool.lua13
-rw-r--r--subagents/luatool.lua26
-rw-r--r--subagents/workflow.lua1
4 files changed, 68 insertions, 1 deletions
diff --git a/init.lua b/init.lua
index 2ec5f8c..5f0b411 100644
--- a/init.lua
+++ b/init.lua
@@ -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