summaryrefslogtreecommitdiff
path: root/subagents/luatool.lua
diff options
context:
space:
mode:
Diffstat (limited to 'subagents/luatool.lua')
-rw-r--r--subagents/luatool.lua80
1 files changed, 36 insertions, 44 deletions
diff --git a/subagents/luatool.lua b/subagents/luatool.lua
index 53966aa..dee7398 100644
--- a/subagents/luatool.lua
+++ b/subagents/luatool.lua
@@ -1,11 +1,12 @@
-- 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. Optional inline agent profiles are overlaid
--- for this execution only; they are never persisted or added to discovery.
+-- 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
@@ -37,13 +38,8 @@
--
-- 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.
+-- 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")
@@ -69,7 +65,8 @@ 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 function build_env(schedule)
+ schedule = schedule or workflow.workflow
local env = {
assert = assert,
error = error,
@@ -85,7 +82,10 @@ local function build_env()
math = shallow_copy(math),
utf8 = shallow_copy(utf8),
print = function() end,
- subagents = { workflow = workflow.workflow },
+ subagents = {
+ workflow = schedule,
+ workflows = workflow.workflows,
+ },
}
env._G = env
return env
@@ -205,13 +205,10 @@ 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)
+function M.handle(input, profiles, context)
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
@@ -221,40 +218,35 @@ function M.handle(input, profiles)
return "Error: " .. profiles_err
end
- local chunk, load_err = load(input.source, CHUNK_NAME, "t", build_env())
+ 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
- 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_for_run,
- 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)
+ return format_return(built)
end
return M