summaryrefslogtreecommitdiff
path: root/subagents/luatool.lua
blob: 8aaa5c9913138371bc171a3b69a15bd43b1d2d3f (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
-- 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

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

-- 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