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
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
|
-- subagents/luatool.lua
--
-- The `subagents.lua` model-facing tool: run a transient, model-authored Lua
-- workflow without writing a definition to disk. Source can start one with
-- `subagents.workflow(function(ctx) ... end)`, which immediately returns a
-- workflow id, or inspect a prior run through `subagents.workflows[id]` and
-- return any model-visible value. Optional inline agent profiles are overlaid
-- for workflows started by this call only; they are never persisted or added
-- to discovery.
--
-- 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
-- on each background workflow coroutine for its full execution. Top-level
-- source evaluation gets the same budget before it can schedule anything.
local workflow = require("subagents.workflow")
local run = require("subagents.run")
local spawn = require("subagents.spawn")
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(schedule)
schedule = schedule or workflow.workflow
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 = schedule,
workflows = workflow.workflows,
},
}
env._G = env
return env
end
M.build_env = build_env
-- Overlay workflow-local profiles without mutating the activation-time set.
-- Inline names intentionally win, matching normal layered profile precedence
-- while keeping their lifetime bounded to this one execute() call.
local function workflow_profiles(inline, discovered)
if inline == nil then
return discovered, nil
end
if type(inline) ~= "table" then
return nil, "agents must be an array"
end
local count = 0
for key in pairs(inline) do
if type(key) ~= "number" then
return nil, "agents must be an array"
end
count = count + 1
end
if count ~= #inline then
return nil, "agents must be a dense array"
end
local base = spawn.profiles(discovered)
local by_name = {}
for name, profile in pairs(base.by_name or {}) do
by_name[name] = profile
end
local seen = {}
for index, raw in ipairs(inline) do
if type(raw) ~= "table" then
return nil, string.format("agents[%d] must be an object", index)
end
local name = raw.name
if type(name) ~= "string" or name:match("^%s*$") then
return nil, string.format("agents[%d].name must be a non-empty string", index)
end
if seen[name] then
return nil, string.format("duplicate inline agent '%s'", name)
end
local system_prompt = raw.system_prompt
if type(system_prompt) ~= "string" or system_prompt:match("^%s*$") then
return nil, string.format("agents[%d].system_prompt must be a non-empty string", index)
end
if raw.description ~= nil and type(raw.description) ~= "string" then
return nil, string.format("agents[%d].description must be a string when given", index)
end
seen[name] = true
by_name[name] = {
name = name,
description = raw.description or "",
body = system_prompt,
layer = "workflow",
}
end
local list = {}
for _, profile in pairs(by_name) do
list[#list + 1] = profile
end
table.sort(list, function(a, b) return a.name < b.name end)
return { list = list, by_name = by_name, warnings = base.warnings or {} }, nil
end
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, context)
if type(input) ~= "table" then
return "Error: expected an input object"
end
if type(input.source) ~= "string" or input.source == "" then
return "Error: source is required and must be a non-empty string"
end
local profiles_for_run, profiles_err = workflow_profiles(input.agents, profiles)
if not profiles_for_run then
return "Error: " .. profiles_err
end
local function on_resume(co)
debug.sethook(co, budget_hook, "", INSTRUCTION_BUDGET)
end
local function on_yield(co)
debug.sethook(co)
end
local function schedule(fn)
return workflow.start(workflow.workflow(fn), {
max_jobs = MAX_JOBS,
profiles = profiles_for_run,
tool_call_id = type(context) == "table" and context.tool_call_id or nil,
on_resume = on_resume,
on_yield = on_yield,
})
end
local chunk, load_err = load(input.source, CHUNK_NAME, "t", build_env(schedule))
if not chunk then
return "Error: source did not compile: " .. tostring(load_err)
end
local co = coroutine.running()
if co then debug.sethook(co, budget_hook, "", INSTRUCTION_BUDGET) end
local built_ok, built = pcall(chunk)
if co then debug.sethook(co) end
if not built_ok then
return "Error: source failed to run: " .. tostring(built)
end
return format_return(built)
end
return M
|