lua-agent-lab / space /lab.lua
jeorgexyz's picture
Publish reproducible Lua agent experiments and traces
98acb70 verified
Raw History Blame Contribute Delete
5.88 kB
-- The web adapter only supplies a backend and an isolated filesystem.
-- Parsing, dispatch, approval, recovery, repetition and eviction remain Lua.
local Agent = require('agent')
local tools = require('tools')
local trace = require('trace')
local protocol = require('protocol')
local tasks = require('eval.tasks.tasks').tasks
local M = {}
function M.tasks_json()
local out = {}
for _, t in ipairs(tasks) do
out[#out + 1] = {id=t.id, prompt=t.prompt, tools=t.tools,
budget=t.budget or 4096, approve=t.approve == true, script=t.script}
end
return trace.encode(out)
end
function M.registry(names, files)
local reg = tools.registry()
for _, name in ipairs(names) do
local spec = {}
for k, v in pairs(assert(tools[name], 'unknown tool')) do spec[k] = v end
if name == 'read_file' then
spec.run = function(a)
local body = files[a.path]
if body == nil then error('cannot open ' .. a.path .. ': No such file', 0) end
if #body > tools.MAX_OBSERVATION then
return body:sub(1, tools.MAX_OBSERVATION) .. '\n[truncated]'
end
return body
end
elseif name == 'write_file' then
spec.run = function(a)
-- These keys never become host filesystem paths.
if #a.path > 200 or #a.text > 8192 then error('virtual file too large', 0) end
local count = 0
for _ in pairs(files) do count = count + 1 end
if count >= 32 and files[a.path] == nil then error('virtual file limit reached', 0) end
files[a.path] = a.text
return string.format('wrote %d bytes to %s', #a.text, a.path)
end
elseif name == 'list_dir' then
spec.run = function(a)
local prefix = a.path or '.'
prefix = prefix:gsub('^%./', ''):gsub('/$', '')
if prefix == '.' then prefix = '' end
if prefix ~= '' then prefix = prefix .. '/' end
local names, seen = {}, {}
for path in pairs(files) do
if path:sub(1, #prefix) == prefix then
local name = path:sub(#prefix + 1):match('^[^/]+')
if name and not seen[name] then names[#names+1] = name; seen[name] = true end
end
end
table.sort(names)
return table.concat(names, '\n')
end
elseif name == 'calc' then
local calculate = spec.run
spec.run = function(a)
if #a.expr > 512 then error('expression exceeds 512 characters', 0) end
return calculate(a)
end
end
reg:add(spec)
end
return reg
end
function M.run(config_json, files_json, factory)
local o = assert(trace.decode(config_json))
local files = assert(trace.decode(files_json))
local task
for _, t in ipairs(tasks) do if t.id == o.task_id then task = t end end
local names = o.tool_names or (task and task.tools) or {'calc','read_file','list_dir','write_file'}
local reg = M.registry(names, files)
local backend, tokenizer
if o.model == 'scripted' then
assert(task and o.prompt == task.prompt, 'Scripted replay requires an unchanged preset task.')
local i = 0
backend = {complete=function()
i = i + 1
return task.script[i] or 'CALL: {"tool":"answer","args":{"text":"(script exhausted)"}}', {}
end}
tokenizer = require('bpe').approximate()
else
backend, tokenizer = factory(reg, config_json)
end
local old_render = protocol.render_system
if o.model == 'tiny' then protocol.render_system = protocol.render_compact end
local fatal_on = {}
if o.error_recovery == false or o.parse_recovery == false then fatal_on.parse_error = true end
if o.error_recovery == false or o.tool_recovery == false then
fatal_on.tool_error = true; fatal_on.timeout = true
end
local approvals = {}
local agent = Agent.new({backend=backend, tools=reg, tokenizer=tokenizer,
max_steps=o.max_steps or 10, max_tokens=o.context_limit or 4096,
repeat_threshold=o.loop_detector == false and 10000 or 3,
policy=o.policy or 'elide_observations', fatal_on=fatal_on,
approve=function(call)
local allowed = o.approval_gate == false or o.approve_write == true
approvals[#approvals+1] = {call=call, allowed=allowed, gate_enabled=o.approval_gate ~= false}
return allowed
end})
local answer, reason, steps = agent:run(o.prompt)
protocol.render_system = old_render
local evicted = false
for _, s in ipairs(steps) do if s.evicted then evicted = true end end
local passed, why
if task and o.prompt == task.prompt and not o.decoder_trial then
-- Preserve the original judges, redirecting their out.txt checks to
-- the same in-memory files used by the public tools.
local old_open, old_remove = io.open, os.remove
io.open = function(path)
if files[path] == nil then return nil end
return {read=function() return files[path] end, close=function() end}
end
os.remove = function(path) files[path] = nil; return true end
local judge = o.model == 'scripted' and task.check or task.solved
local ok, a, b = pcall(judge, answer, steps, {evicted=evicted, reason=reason})
io.open, os.remove = old_open, old_remove
if not ok then error(a) end
passed, why = a, b
end
return trace.encode({answer=answer, reason=reason, steps=steps, passed=passed,
judge_note=why, evicted=evicted, approvals=approvals, files=files})
end
return M