lua-agent-lab / trace.lua
jeorgexyz's picture
Publish reproducible Lua agent experiments and traces
98acb70 verified
Raw History Blame Contribute Delete
9.95 kB
-- trace.lua - JSONL record and replay, plus the minimal JSON it needs.
--
-- The highest-value file in the repo for its size. Every step is appended as
-- one JSON line; backend/replay.lua reads those lines back. That single fact
-- buys:
-- - a test suite that needs no API key and no network
-- - examples/ transcripts that are reproducible rather than illustrative
-- - a debugging story: re-run a failure exactly, as many times as you like
--
-- No dependencies is load-bearing here, same as repo 1, so the JSON codec is
-- written out rather than pulled in. It only has to handle the step schema.
local trace = {}
--------------------------------------------------------------------------
-- JSON encode
--------------------------------------------------------------------------
local ESCAPES = {
['"'] = '\\"', ['\\'] = '\\\\', ['\b'] = '\\b', ['\f'] = '\\f',
['\n'] = '\\n', ['\r'] = '\\r', ['\t'] = '\\t',
}
local function escape_str(s)
return (s:gsub('[%c"\\]', function(c)
return ESCAPES[c] or string.format('\\u%04x', c:byte())
end))
end
-- Arrays are tables whose keys are exactly 1..n. Everything else, including
-- the empty table, encodes as an object -- empty `args` must be `{}`.
local function is_array(t)
local n = 0
for _ in pairs(t) do n = n + 1 end
for i = 1, n do
if t[i] == nil then return false end
end
return n > 0
end
-- Pin an object's key order for encoding.
--
-- Key order is semantically meaningless in JSON, which is why encode sorts
-- alphabetically by default: byte-stable traces that diff cleanly.
--
-- It stops being meaningless the moment the JSON is a SCHEMA that gets
-- compiled to a grammar. llama.cpp emits object properties in declaration
-- order, so the sort silently rewrote backend/ollama.lua's turn schema from
-- {thought, call} to {call, thought} and from {tool, args} to {args, tool} --
-- forcing the model to choose a tool before writing its reasoning, and to
-- write arguments before naming the tool they belonged to. qwen2.5 then
-- re-called a tool it had already run, five times in a row, and looked like
-- a model too small for the job.
--
-- trace.ordered({ thought = "...", call = {...} }, { "thought", "call" })
--
-- Keys not listed are appended in sorted order, so this never drops data.
function trace.ordered(tbl, order)
return setmetatable(tbl, { __jsonorder = order })
end
function trace.encode(v)
local ty = type(v)
if v == nil then
return "null"
elseif ty == "boolean" then
return tostring(v)
elseif ty == "number" then
-- %.14g round-trips a double without trailing float noise
return (v % 1 == 0 and math.abs(v) < 1e15)
and string.format("%d", v)
or string.format("%.14g", v)
elseif ty == "string" then
return '"' .. escape_str(v) .. '"'
elseif ty == "table" then
local out = {}
if is_array(v) then
for i = 1, #v do out[#out + 1] = trace.encode(v[i]) end
return "[" .. table.concat(out, ",") .. "]"
end
-- Sort keys so a re-encoded trace is byte-stable and diffable --
-- unless the caller pinned an order with trace.ordered.
local keys = {}
local pinned = getmetatable(v)
pinned = pinned and pinned.__jsonorder
if pinned then
for _, k in ipairs(pinned) do
if v[k] ~= nil then keys[#keys + 1] = k end
end
-- Anything not named in the order still has to be emitted.
local named = {}
for _, k in ipairs(pinned) do named[k] = true end
local rest = {}
for k in pairs(v) do
if not named[k] then rest[#rest + 1] = k end
end
table.sort(rest, function(a, b) return tostring(a) < tostring(b) end)
for _, k in ipairs(rest) do keys[#keys + 1] = k end
else
for k in pairs(v) do keys[#keys + 1] = k end
table.sort(keys, function(a, b) return tostring(a) < tostring(b) end)
end
for _, k in ipairs(keys) do
out[#out + 1] = '"' .. escape_str(tostring(k)) .. '":' .. trace.encode(v[k])
end
return "{" .. table.concat(out, ",") .. "}"
end
error("cannot encode " .. ty)
end
--------------------------------------------------------------------------
-- JSON decode -- recursive descent, enough for the schema above
--------------------------------------------------------------------------
local Parser = {}
Parser.__index = Parser
function Parser:err(msg)
error(string.format("json: %s at byte %d", msg, self.pos), 0)
end
function Parser:skip_ws()
local _, e = self.s:find("^[ \t\r\n]*", self.pos)
self.pos = e + 1
end
function Parser:peek()
return self.s:sub(self.pos, self.pos)
end
function Parser:expect(c)
if self:peek() ~= c then self:err("expected " .. c) end
self.pos = self.pos + 1
end
local UNESCAPES = {
['"'] = '"', ['\\'] = '\\', ['/'] = '/', b = '\b',
f = '\f', n = '\n', r = '\r', t = '\t',
}
function Parser:parse_string()
self:expect('"')
local buf = {}
while true do
local c = self:peek()
if c == "" then self:err("unterminated string") end
self.pos = self.pos + 1
if c == '"' then
return table.concat(buf)
elseif c == '\\' then
local esc = self:peek()
self.pos = self.pos + 1
if esc == "u" then
local hex = self.s:sub(self.pos, self.pos + 3)
if not hex:match("^%x%x%x%x$") then self:err("bad unicode escape") end
self.pos = self.pos + 4
local cp = tonumber(hex, 16)
buf[#buf + 1] = (cp < 128) and string.char(cp) or utf8.char(cp)
elseif UNESCAPES[esc] then
buf[#buf + 1] = UNESCAPES[esc]
else
self:err("bad escape " .. esc)
end
else
buf[#buf + 1] = c
end
end
end
function Parser:parse_value()
self:skip_ws()
local c = self:peek()
if c == '"' then
return self:parse_string()
elseif c == "{" then
self.pos = self.pos + 1
local obj = {}
self:skip_ws()
if self:peek() == "}" then self.pos = self.pos + 1 return obj end
while true do
self:skip_ws()
local k = self:parse_string()
self:skip_ws()
self:expect(":")
obj[k] = self:parse_value()
self:skip_ws()
local d = self:peek()
self.pos = self.pos + 1
if d == "}" then return obj end
if d ~= "," then self:err("expected comma or close-brace") end
end
elseif c == "[" then
self.pos = self.pos + 1
local arr = {}
self:skip_ws()
if self:peek() == "]" then self.pos = self.pos + 1 return arr end
while true do
arr[#arr + 1] = self:parse_value()
self:skip_ws()
local d = self:peek()
self.pos = self.pos + 1
if d == "]" then return arr end
if d ~= "," then self:err("expected comma or close-bracket") end
end
elseif self.s:find("^true", self.pos) then
self.pos = self.pos + 4 return true
elseif self.s:find("^false", self.pos) then
self.pos = self.pos + 5 return false
elseif self.s:find("^null", self.pos) then
self.pos = self.pos + 4 return nil
else
local num = self.s:match("^%-?%d+%.?%d*[eE]?[-+]?%d*", self.pos)
if not num or num == "" then self:err("unexpected character " .. c) end
self.pos = self.pos + #num
return tonumber(num)
end
end
-- Returns value, err. Never raises: callers feed the error text straight back
-- to the model as an observation, which is how parse recovery works.
function trace.decode(str)
local p = setmetatable({ s = str, pos = 1 }, Parser)
local ok, result = pcall(function()
local v = p:parse_value()
p:skip_ws()
if p.pos <= #p.s then p:err("trailing content") end
return v
end)
if not ok then return nil, result end
return result
end
--------------------------------------------------------------------------
-- JSONL trace files
--------------------------------------------------------------------------
local Trace = {}
Trace.__index = Trace
-- mode: "record" | "off"
function trace.open(path, mode)
local self = setmetatable({ path = path, mode = mode or "off", n = 0 }, Trace)
if self.mode == "record" then
if not path then error("trace.open: record mode needs a path") end
local fh, err = io.open(path, "w")
if not fh then error("trace.open: " .. tostring(err)) end
self.fh = fh
end
return self
end
-- One line per step. Schema (stable; examples/ depend on it):
-- { step=, prompt=, raw=, thought=, call={tool=,args=}, observation=,
-- status=, tokens={prompt=,completion=,window=}, ms= }
function Trace:write(record)
self.n = self.n + 1
if not self.fh then return end
self.fh:write(trace.encode(record), "\n")
self.fh:flush() -- a crashed run must still leave a readable trace
end
function Trace:close()
if self.fh then self.fh:close() self.fh = nil end
end
-- Read a trace back into a step list.
function trace.read(path)
local fh, err = io.open(path, "r")
if not fh then error("trace.read: " .. tostring(err)) end
local steps, lineno = {}, 0
for line in fh:lines() do
lineno = lineno + 1
if line:match("%S") then
local rec, derr = trace.decode(line)
if not rec then
fh:close()
error(string.format("trace.read: %s:%d: %s", path, lineno, derr))
end
steps[#steps + 1] = rec
end
end
fh:close()
return steps
end
return trace