Spaces:
Running on Zero
Running on Zero
Download test.lua from jeorgexyz/lua-agent-lab: direct link, hf CLI and curl.
- Browser
- Download file 40.3 kB
-
https://huggingface.co/spaces/jeorgexyz/lua-agent-lab/resolve/main/test.lua
- Command line
-
hf download hf://spaces/jeorgexyz/lua-agent-lab/test.lua
-
curl -L -o test.lua https://huggingface.co/spaces/jeorgexyz/lua-agent-lab/resolve/main/test.lua
40.3 kB
| -- test.lua - The whole test suite. Runs offline, no model, no key. | |
| -- | |
| -- lua54 test.lua | |
| -- | |
| -- Everything here must stay runnable by someone who just cloned the repo and | |
| -- has no checkpoint. Tests that need a model live in eval/, not here. | |
| package.path = "./?.lua;" .. package.path | |
| local pass, fail = 0, 0 | |
| local current = "?" | |
| local function group(name) current = name end | |
| local function check(desc, ok, detail) | |
| if ok then | |
| pass = pass + 1 | |
| else | |
| fail = fail + 1 | |
| io.write(string.format("FAIL %s: %s\n", current, desc)) | |
| if detail then io.write(" " .. tostring(detail) .. "\n") end | |
| end | |
| end | |
| local function eq(desc, got, want) | |
| check(desc, got == want, string.format("got %s, want %s", | |
| tostring(got), tostring(want))) | |
| end | |
| -------------------------------------------------------------------------- | |
| group("trace/json") | |
| -------------------------------------------------------------------------- | |
| local trace = require("trace") | |
| do | |
| local cases = { | |
| { tool = "calc", args = { expr = "4871 * 209" } }, | |
| { a = {}, b = { 1, 2, 3 }, d = true, e = 1.5, f = 42 }, | |
| { s = 'quote " backslash \\ newline \n tab \t' }, | |
| { nested = { deep = { deeper = { "x" } } } }, | |
| { empty_args = {} }, | |
| } | |
| for i, v in ipairs(cases) do | |
| local enc = trace.encode(v) | |
| local dec, err = trace.decode(enc) | |
| check("round-trip " .. i .. " decodes", dec ~= nil, err) | |
| if dec then | |
| eq("round-trip " .. i .. " is stable", trace.encode(dec), enc) | |
| end | |
| end | |
| -- Key order must be deterministic or traces stop diffing cleanly | |
| eq("keys sorted", trace.encode({ z = 1, a = 2, m = 3 }), | |
| '{"a":2,"m":3,"z":1}') | |
| eq("empty table is an object", trace.encode({}), "{}") | |
| eq("integers have no decimal point", trace.encode({ n = 42 }), '{"n":42}') | |
| -- Decode must reject rather than raise | |
| for _, bad in ipairs({ "{bad}", '{"a":1} trailing', '{"a":', '"unterminated' }) do | |
| local v, e = trace.decode(bad) | |
| check("rejects " .. bad, v == nil and type(e) == "string", e) | |
| end | |
| end | |
| -------------------------------------------------------------------------- | |
| group("protocol") | |
| -------------------------------------------------------------------------- | |
| local ok_protocol, protocol = pcall(require, "protocol") | |
| if ok_protocol and protocol.parse then | |
| local p = protocol.parse('THOUGHT: I should multiply.\nCALL: {"tool":"calc","args":{"expr":"2*3"}}') | |
| if check("parses a well-formed call", p ~= nil and p.kind == "call") then end | |
| if p and p.kind == "call" then | |
| eq("extracts tool name", p.tool, "calc") | |
| eq("extracts args", p.args and p.args.expr, "2*3") | |
| eq("extracts thought", p.thought, "I should multiply.") | |
| end | |
| local a = protocol.parse('THOUGHT: done\nCALL: {"tool":"answer","args":{"text":"6"}}') | |
| check("answer is a tool call", a ~= nil and a.kind == "answer", a and a.kind) | |
| if a and a.kind == "answer" then eq("answer text", a.text, "6") end | |
| -- Malformed input must return an error string, never raise: that string | |
| -- is fed back to the model as the observation. | |
| for _, bad in ipairs({ | |
| "no call here at all", | |
| 'CALL: {"tool":"calc"', | |
| 'CALL: {"args":{}}', | |
| 'CALL: {"tool":"calc","args":"not an object"}', | |
| }) do | |
| local v, e = protocol.parse(bad) | |
| check("rejects: " .. bad:sub(1, 34), v == nil and type(e) == "string", e) | |
| end | |
| end | |
| -------------------------------------------------------------------------- | |
| group("tools") | |
| -------------------------------------------------------------------------- | |
| local ok_tools, tools = pcall(require, "tools") | |
| if ok_tools and tools.registry then | |
| local reg = tools.registry() | |
| reg:add(tools.calc) | |
| eq("calc multiplies", select(2, reg:invoke("calc", { expr = "4871 * 209" })), "1018039") | |
| eq("calc adds", select(2, reg:invoke("calc", { expr = "2 + 2" })), "4") | |
| -- Validation failures are observations, not crashes | |
| local ok1, err1 = reg:validate("calc", {}) | |
| check("missing required arg rejected", not ok1, err1) | |
| local ok2, err2 = reg:validate("nope", { expr = "1" }) | |
| check("unknown tool rejected", not ok2, err2) | |
| -- calc must not be a Lua eval hole | |
| local _, res = reg:invoke("calc", { expr = "os.exit(1)" }) | |
| check("calc refuses non-arithmetic", tostring(res):match("arithmetic") ~= nil, res) | |
| -- The approval gate must default to refusing | |
| reg:add(tools.write_file) | |
| local _, _, status = reg:invoke("write_file", | |
| { path = "x.txt", text = "y" }, function() return false end) | |
| eq("denied write reports denied", status, "denied") | |
| end | |
| -------------------------------------------------------------------------- | |
| group("context") | |
| -------------------------------------------------------------------------- | |
| local ok_ctx, context = pcall(require, "context") | |
| if ok_ctx and context.new then | |
| -- A stand-in tokenizer: one token per 4 chars. The real one is only | |
| -- needed to prove the counts are real, not to prove the policies work. | |
| local fake_tok = { encode = function(_, s) | |
| local t = {} | |
| for _ = 1, math.max(1, math.ceil(#s / 4)) do t[#t + 1] = 0 end | |
| return t | |
| end } | |
| local ctx = context.new(fake_tok, 100, "elide_observations") | |
| eq("counts via the tokenizer", ctx:count("aaaaaaaa"), 2) | |
| eq("count is memoized", ctx:count("aaaaaaaa"), 2) | |
| local steps = {} | |
| for i = 1, 10 do | |
| steps[i] = { | |
| thought = "t" .. i, | |
| call = { tool = "read_file", args = { path = "f" .. i } }, | |
| observation = string.rep("x", 200), | |
| } | |
| end | |
| local kept, note = ctx:fit(10, steps) | |
| check("eviction fires under pressure", note ~= nil and note ~= "", note) | |
| check("eviction keeps recent steps", #kept > 0) | |
| check("result fits the budget", | |
| ctx:count(table.concat({ note or "" })) < 100) | |
| -- The model must be TOLD it lost information. Silent truncation is the | |
| -- single most common context bug. | |
| check("note names what was dropped", | |
| note and (note:match("elid") or note:match("drop") or note:match("omit")), note) | |
| -- fit() must never hand back a window bigger than the budget. The hard | |
| -- case is a SINGLE observation larger than the whole budget: no policy | |
| -- that evicts whole steps can help, so the observation itself gets cut. | |
| -- Before that backstop existed, this returned ~3x the budget and the | |
| -- ledger reported a number the model never received. | |
| local tight = context.new(fake_tok, 120, "elide_observations") | |
| local huge = { { thought = "t", call = { tool = "read_file", args = {} }, | |
| observation = string.rep("y", 8000) } } | |
| local kept2 = tight:fit(10, huge) | |
| check("one oversized observation still fits the budget", | |
| tight:count_steps(kept2) <= 110, | |
| "got " .. tight:count_steps(kept2) .. " tokens, budget 120 minus 10 fixed") | |
| -- And the same must hold across every policy. | |
| for _, name in ipairs({ "drop_oldest", "elide_observations", "summarize" }) do | |
| local c = context.new(fake_tok, 120, name) | |
| local k = c:fit(10, huge) | |
| check("policy " .. name .. " respects the budget", | |
| c:count_steps(k) <= 110, "got " .. c:count_steps(k)) | |
| end | |
| end | |
| -------------------------------------------------------------------------- | |
| group("grammar") | |
| -------------------------------------------------------------------------- | |
| local ok_gram, grammar = pcall(require, "grammar") | |
| if ok_gram and grammar.compile then | |
| -- A tiny fake tokenizer with a known vocabulary lets us test the token | |
| -- machine without loading a 60MB checkpoint. Character-level plus a few | |
| -- multi-character tokens, which is the case that actually matters: | |
| -- BPE tokens straddle the character boundaries the grammar cares about. | |
| local pieces = { | |
| [0] = "<unk>", [1] = "<s>", [2] = "</s>", | |
| } | |
| local n = 3 | |
| local function add(s) pieces[n] = s n = n + 1 return n - 1 end | |
| for c = 32, 126 do add(string.char(c)) end | |
| local multi = { '{"', 'tool', '":"', 'calc', 'args', '"}', '}}', '","', | |
| 'read_file', 'answer', 'text', 'expr', 'path', '2', '09', '4871' } | |
| for _, m in ipairs(multi) do add(m) end | |
| local vocab_size = n | |
| local fake_tok = { | |
| vocab_size = vocab_size, | |
| decode = function(_, id) return pieces[id] or "" end, | |
| } | |
| local reg = tools.registry() | |
| reg:add(tools.calc) | |
| reg:add(tools.read_file) | |
| local g = grammar.compile(reg, fake_tok, vocab_size) | |
| check("compiles", g ~= nil) | |
| if g then | |
| -- Drive the machine greedily and confirm the result parses. The mask | |
| -- is the only thing steering here; there is no model in this test. | |
| local m = g:start() | |
| local out, guard = {}, 0 | |
| while not m:is_done() and guard < 400 do | |
| guard = guard + 1 | |
| local logits = {} | |
| for i = 1, vocab_size do logits[i] = 0 end | |
| local legal = m:apply(logits, vocab_size) | |
| check("some token is always legal (step " .. guard .. ")", legal > 0) | |
| if legal == 0 then break end | |
| -- Take the lowest-id legal token: an adversarial "model" that | |
| -- knows nothing. If this still parses, the grammar is doing it. | |
| local chosen | |
| for i = 1, vocab_size do | |
| if logits[i] > -math.huge then chosen = i - 1 break end | |
| end | |
| out[#out + 1] = fake_tok:decode(chosen) | |
| m:advance(chosen) | |
| end | |
| check("machine terminates", m:is_done(), "after " .. guard .. " tokens") | |
| local text = table.concat(out) | |
| local decoded, derr = trace.decode(text) | |
| check("adversarial decode still parses: " .. text, decoded ~= nil, derr) | |
| -- A required argument must be non-empty BY CONSTRUCTION, not by | |
| -- later validation. tools.lua treats an empty required arg as a | |
| -- missing one, so a grammar that permits "" emits calls that are | |
| -- syntactically perfect and guaranteed to fail -- and the approval | |
| -- gate is never even reached, because validation rejects first. | |
| -- stories42M hit this four times running on the denied-write task. | |
| if decoded and decoded.args then | |
| for k, v in pairs(decoded.args) do | |
| check("argument " .. k .. " is non-empty", v ~= "", | |
| "the grammar allowed an empty required value") | |
| end | |
| end | |
| if decoded then | |
| check("has a tool field", decoded.tool ~= nil) | |
| check("tool is a registered name", | |
| decoded.tool == "calc" or decoded.tool == "read_file" | |
| or decoded.tool == "answer", decoded.tool) | |
| check("has an args object", type(decoded.args) == "table") | |
| end | |
| -- Directly: from the start of a value, the closing quote must be | |
| -- illegal. This is the invariant the walk above only samples. | |
| local m2 = g:start() | |
| local prefix = '{"tool":"calc","args":{"expr":"' | |
| local by_char = {} | |
| for id = 0, vocab_size - 1 do | |
| local t = fake_tok:decode(id) | |
| if #t == 1 and by_char[t] == nil then by_char[t] = id end | |
| end | |
| local drove = true | |
| for i = 1, #prefix do | |
| local id = by_char[prefix:sub(i, i)] | |
| if id == nil then drove = false break end | |
| local okc = pcall(function() m2:advance(id) end) | |
| if not okc then drove = false break end | |
| end | |
| check("drove the machine to a value start", drove) | |
| if drove then | |
| local lg = {} | |
| for i = 1, vocab_size do lg[i] = 0 end | |
| m2:apply(lg, vocab_size) | |
| check("closing quote illegal at an empty required value", | |
| lg[by_char['"'] + 1] == -math.huge) | |
| -- and legal once one character is in | |
| m2:advance(by_char["7"]) | |
| local lg2 = {} | |
| for i = 1, vocab_size do lg2[i] = 0 end | |
| m2:apply(lg2, vocab_size) | |
| check("closing quote legal after one character", | |
| lg2[by_char['"'] + 1] > -math.huge) | |
| end | |
| -- finish() force-completes a call; it must not produce the empty | |
| -- value the minimum exists to forbid. | |
| local m3 = g:start() | |
| for i = 1, #prefix do | |
| local id = by_char[prefix:sub(i, i)] | |
| if id then pcall(function() m3:advance(id) end) end | |
| end | |
| local forced = trace.decode(m3:text() .. m3:finish()) | |
| check("forced finish still yields a parseable call", forced ~= nil) | |
| if forced and forced.args then | |
| for k, v in pairs(forced.args) do | |
| check("forced finish leaves " .. k .. " non-empty", v ~= "") | |
| end | |
| end | |
| end | |
| end | |
| -------------------------------------------------------------------------- | |
| group("agent") | |
| -------------------------------------------------------------------------- | |
| local ok_agent, Agent = pcall(require, "agent") | |
| if ok_agent and Agent.new and ok_tools then | |
| -- A scripted backend: the cheapest possible way to test the loop's | |
| -- control flow, including every arm of the error taxonomy. | |
| local function scripted(replies) | |
| local i = 0 | |
| return { complete = function(_, _, _) | |
| i = i + 1 | |
| return replies[i] or 'THOUGHT: giving up\nCALL: {"tool":"answer","args":{"text":"none"}}', {} | |
| end } | |
| end | |
| local reg = tools.registry() | |
| reg:add(tools.calc) | |
| do | |
| local a = Agent.new({ | |
| backend = scripted({ | |
| 'THOUGHT: multiply\nCALL: {"tool":"calc","args":{"expr":"4871 * 209"}}', | |
| 'THOUGHT: done\nCALL: {"tool":"answer","args":{"text":"1018039"}}', | |
| }), | |
| tools = reg, tokenizer = { encode = function(_, s) return { #s } end }, | |
| }) | |
| local answer, reason, steps = a:run("What is 4871 * 209?") | |
| eq("happy path answers", answer, "1018039") | |
| eq("happy path reason", reason, "answer") | |
| eq("happy path took two steps", #steps, 2) | |
| eq("observation fed back", steps[1].observation, "1018039") | |
| end | |
| do -- parse error recovers rather than crashing | |
| local a = Agent.new({ | |
| backend = scripted({ | |
| 'THOUGHT: oops\nCALL: {"tool":"calc"', | |
| 'THOUGHT: retry\nCALL: {"tool":"calc","args":{"expr":"1+1"}}', | |
| 'THOUGHT: done\nCALL: {"tool":"answer","args":{"text":"2"}}', | |
| }), | |
| tools = reg, tokenizer = { encode = function(_, s) return { #s } end }, | |
| }) | |
| local answer, _, steps = a:run("one plus one") | |
| eq("recovers from a parse error", answer, "2") | |
| eq("parse error is a named status", steps[1].status, "parse_error") | |
| end | |
| do -- tool error becomes an observation | |
| local a = Agent.new({ | |
| backend = scripted({ | |
| 'THOUGHT: bad\nCALL: {"tool":"calc","args":{"expr":"not math"}}', | |
| 'THOUGHT: done\nCALL: {"tool":"answer","args":{"text":"recovered"}}', | |
| }), | |
| tools = reg, tokenizer = { encode = function(_, s) return { #s } end }, | |
| }) | |
| local answer, _, steps = a:run("x") | |
| eq("tool error is a named status", steps[1].status, "tool_error") | |
| check("error text reaches the model", (steps[1].observation or ""):match("[Ee]rror") ~= nil, | |
| steps[1].observation) | |
| eq("recovers from a tool error", answer, "recovered") | |
| end | |
| do -- loop detection | |
| local same = 'THOUGHT: again\nCALL: {"tool":"calc","args":{"expr":"1+1"}}' | |
| local a = Agent.new({ | |
| backend = scripted({ same, same, same, same, same, same, same, same }), | |
| tools = reg, tokenizer = { encode = function(_, s) return { #s } end }, | |
| repeat_threshold = 3, max_steps = 8, | |
| }) | |
| local _, _, steps = a:run("spin") | |
| local saw_repeat = false | |
| for _, s in ipairs(steps) do | |
| if s.status == "repeat" then saw_repeat = true end | |
| end | |
| check("loop detector fires", saw_repeat) | |
| end | |
| do -- step ceiling | |
| local a = Agent.new({ | |
| backend = scripted({}), | |
| tools = reg, tokenizer = { encode = function(_, s) return { #s } end }, | |
| max_steps = 3, | |
| }) | |
| local _, reason = a:run("x") | |
| check("terminates on its own", reason == "answer" or reason == "max_steps", reason) | |
| end | |
| end | |
| -------------------------------------------------------------------------- | |
| group("http") | |
| -------------------------------------------------------------------------- | |
| -- No network, no key, no tokens spent. What is covered here is the request | |
| -- shape and every branch of response handling; what is NOT covered is | |
| -- whether the live API accepts the request. The README says so plainly. | |
| -- | |
| -- These are the branches a happy-path live call would never reach anyway: | |
| -- thinking blocks arriving before the text, a refusal at HTTP 200, an error | |
| -- object, a transport failure. | |
| do | |
| local ok_http, http = pcall(require, "backend.http") | |
| if ok_http and http.new then | |
| local sent | |
| local function transport_returning(raw, ok) | |
| return function(body_json) | |
| sent = body_json | |
| return ok ~= false, raw, 0 | |
| end | |
| end | |
| -- Request shape | |
| local be = http.new({ transport = transport_returning( | |
| '{"content":[{"type":"text","text":"hi"}],"stop_reason":"end_turn"}') }) | |
| be:complete("TASK: add 2 and 2") | |
| local req = trace.decode(sent or "") | |
| check("request decodes", req ~= nil) | |
| if req then | |
| eq("model defaults to opus 5", req.model, "claude-opus-5") | |
| eq("temperature is deterministic", req.temperature, 0) | |
| check("max_tokens is set", type(req.max_tokens) == "number") | |
| check("system prompt carries the format", (req.system or ""):match("CALL:") ~= nil) | |
| check("stop sequences sent", type(req.stop_sequences) == "table") | |
| check("one user message", req.messages and #req.messages == 1 | |
| and req.messages[1].role == "user") | |
| check("prompt reaches the message", | |
| (req.messages[1].content or ""):match("add 2 and 2") ~= nil) | |
| -- Assistant prefill is rejected on current models; the backend | |
| -- must never send one. | |
| local has_assistant = false | |
| for _, m in ipairs(req.messages or {}) do | |
| if m.role == "assistant" then has_assistant = true end | |
| end | |
| check("no assistant prefill", not has_assistant) | |
| end | |
| -- Thinking blocks precede the text on Opus-tier models; taking | |
| -- content[1] blindly would return an empty string. | |
| local text = http.parse_response( | |
| '{"content":[{"type":"thinking","thinking":"hmm"},' .. | |
| '{"type":"text","text":"THOUGHT: ok"},{"type":"text","text":"\\nCALL: {}"}],' .. | |
| '"stop_reason":"end_turn","usage":{"output_tokens":7,"input_tokens":50},' .. | |
| '"model":"claude-opus-5"}') | |
| eq("skips thinking blocks and joins text", | |
| text, "THOUGHT: ok\nCALL: {}") | |
| local _, meta = http.parse_response( | |
| '{"content":[{"type":"text","text":"x"}],"stop_reason":"end_turn",' .. | |
| '"usage":{"output_tokens":7,"input_tokens":50},"model":"claude-opus-5"}') | |
| eq("reports output tokens", meta.tokens, 7) | |
| eq("reports input tokens", meta.input_tokens, 50) | |
| eq("reports the serving model", meta.model, "claude-opus-5") | |
| eq("http is never constrained", meta.constrained, false) | |
| -- A refusal is HTTP 200 with stop_reason "refusal", not an error. | |
| local r_ok, r_err = pcall(http.parse_response, | |
| '{"content":[],"stop_reason":"refusal",' .. | |
| '"stop_details":{"type":"refusal","category":"cyber"}}') | |
| check("refusal raises", not r_ok) | |
| check("refusal names the category", | |
| tostring(r_err):match("cyber") ~= nil, r_err) | |
| -- An API error object. | |
| local e_ok, e_err = pcall(http.parse_response, | |
| '{"type":"error","error":{"type":"authentication_error","message":"bad key"}}') | |
| check("api error raises", not e_ok) | |
| check("api error surfaces the message", | |
| tostring(e_err):match("bad key") ~= nil, e_err) | |
| -- Malformed body (an HTML error page, say). | |
| local m_ok = pcall(http.parse_response, "<html>502</html>") | |
| check("malformed body raises rather than returning junk", not m_ok) | |
| -- Transport failure must surface, not be swallowed. | |
| local be2 = http.new({ transport = transport_returning("no route to host", false) }) | |
| local t_ok, t_err = pcall(function() return be2:complete("x") end) | |
| check("transport failure raises", not t_ok) | |
| check("transport failure keeps the detail", | |
| tostring(t_err):match("no route to host") ~= nil, t_err) | |
| -- The agent loop must treat a backend failure as fatal, not crash. | |
| if ok_tools and ok_agent then | |
| local reg = tools.registry() | |
| reg:add(tools.calc) | |
| local a = Agent.new({ | |
| backend = http.new({ transport = transport_returning("boom", false) }), | |
| tools = reg, | |
| tokenizer = { encode = function(_, s) return { #s } end }, | |
| max_steps = 3, | |
| }) | |
| local answer, reason = a:run("x") | |
| eq("backend failure ends the run as fatal", reason, "fatal") | |
| check("no answer is invented", answer == nil) | |
| end | |
| end | |
| end | |
| -------------------------------------------------------------------------- | |
| group("tmpfile") | |
| -------------------------------------------------------------------------- | |
| -- The transport seam that makes the http and ollama backends testable also | |
| -- means their curl path is never executed by those tests. It had a bug the | |
| -- whole time: os.tmpname() on Windows returns a bare name rooted at the | |
| -- current drive ("\s5tg."), io.open on it fails with permission denied, and | |
| -- the backends indexed the nil handle. Every real request died before it | |
| -- was sent. | |
| -- | |
| -- So this tests the staging directly -- it is the part the seam hides. | |
| do | |
| local ok_tmp, tmpfile = pcall(require, "tmpfile") | |
| if ok_tmp then | |
| local p1 = tmpfile.name(".req") | |
| local p2 = tmpfile.name(".req") | |
| check("names are unique", p1 ~= p2, p1 .. " == " .. p2) | |
| check("name carries the suffix", p1:sub(-4) == ".req", p1) | |
| local path, err = tmpfile.write("hello world", ".txt") | |
| check("writes a file that can actually be opened", path ~= nil, err) | |
| if path then | |
| eq("round-trips the contents", tmpfile.slurp(path), "hello world") | |
| tmpfile.remove(path) | |
| check("remove deletes it", tmpfile.slurp(path) == nil) | |
| end | |
| -- Bodies contain model output: quotes, newlines, braces. | |
| local tricky = '{"tool":"calc","args":{"expr":"2+2"}}\nsecond "line"\n' | |
| local p3 = tmpfile.write(tricky, ".json") | |
| if p3 then | |
| eq("survives quotes and newlines", tmpfile.slurp(p3), tricky) | |
| tmpfile.remove(p3) | |
| end | |
| check("slurp of a missing file returns nil", | |
| tmpfile.slurp(tmpfile.name(".nope")) == nil) | |
| -- remove() is called on cleanup paths where some handles are nil. | |
| local rok = pcall(tmpfile.remove, nil, nil) | |
| check("remove tolerates nils", rok) | |
| end | |
| end | |
| -------------------------------------------------------------------------- | |
| group("ollama") | |
| -------------------------------------------------------------------------- | |
| -- Same fixture approach as the http group: no daemon, no model pull. | |
| do | |
| local ok_ol, ollama = pcall(require, "backend.ollama") | |
| if ok_ol and ollama.new then | |
| local sent | |
| local function transport(raw, ok) | |
| return function(body) sent = body return ok ~= false, raw, 0 end | |
| end | |
| local oreg = tools.registry() | |
| oreg:add(tools.calc) | |
| local be = ollama.new({ format = false, transport = transport( | |
| '{"model":"qwen2.5:0.5b-instruct","response":"THOUGHT: ok\\nCALL: {}",' .. | |
| '"done":true,"eval_count":12,"prompt_eval_count":80}') }) | |
| local text, meta = be:complete("TASK: x") | |
| local req = trace.decode(sent or "") | |
| check("request decodes", req ~= nil) | |
| if req then | |
| eq("default model", req.model, "qwen2.5:0.5b-instruct") | |
| eq("streaming off", req.stream, false) | |
| -- /api/chat: role-separated messages, not system+prompt fields. | |
| check("system turn is first", | |
| req.messages and req.messages[1] and req.messages[1].role == "system") | |
| check("free mode asks for the text format in the system turn", | |
| req.messages and (req.messages[1].content or ""):match("CALL:") ~= nil) | |
| check("the prompt is the user turn", | |
| req.messages and req.messages[2] | |
| and (req.messages[2].content or ""):match("TASK: x") ~= nil) | |
| check("stop sequences set", req.options and type(req.options.stop) == "table") | |
| eq("temperature deterministic", req.options and req.options.temperature, 0) | |
| check("no format schema in free mode", req.format == nil) | |
| end | |
| eq("reads the response field", text, "THOUGHT: ok\nCALL: {}") | |
| eq("reports eval_count as tokens", meta.tokens, 12) | |
| eq("reports prompt_eval_count", meta.input_tokens, 80) | |
| -- This backend must never claim grammar.lua was involved; the whole | |
| -- mechanism/capability distinction rests on it. | |
| eq("never reports itself as constrained", meta.constrained, false) | |
| -- Schema mode: the per-tool JSON schema goes with the request, and | |
| -- the reply is rewrapped into the repo's one text protocol. | |
| local be2 = ollama.new({ tools = oreg, transport = transport( | |
| '{"message":{"content":"{\\"thought\\":\\"mul\\",\\"call\\":' .. | |
| '{\\"tool\\":\\"calc\\",\\"args\\":{\\"expr\\":\\"2+2\\"}}}"},"done":true}') }) | |
| local t2, m2 = be2:complete("x") | |
| local req2 = trace.decode(sent or "") | |
| check("schema sent by default", | |
| req2 and type(req2.format) == "table" and req2.format.type == "object") | |
| eq("schema mode is reported", m2.schema, true) | |
| eq("schema reply is rewrapped into the text protocol", t2, | |
| 'THOUGHT: mul\nCALL: {"args":{"expr":"2+2"},"tool":"calc"}') | |
| local turn2 = protocol.parse(t2) | |
| check("and the rewrapped turn parses", turn2 ~= nil and turn2.tool == "calc") | |
| -- KEY ORDER IS LOAD-BEARING, and nothing else in the repo cares. | |
| -- llama.cpp compiles the schema to GBNF in declaration order, so | |
| -- trace.encode's alphabetical sort put `call` before `thought` and | |
| -- `args` before `tool` -- forcing the model to pick a tool before | |
| -- reasoning, and to write arguments before naming the tool. qwen2.5 | |
| -- then re-called a tool it had already run, five times running, and | |
| -- read as a model too small for the job. It was the encoder. | |
| local raw2 = sent or "" | |
| local ti = raw2:find('"thought"', 1, true) | |
| local ci = raw2:find('"call"', 1, true) | |
| check("schema declares thought before call", ti and ci and ti < ci, | |
| "thought at " .. tostring(ti) .. ", call at " .. tostring(ci)) | |
| local tool_i = raw2:find('"tool"', 1, true) | |
| local args_i = raw2:find('"args"', 1, true) | |
| check("schema declares tool before args", tool_i and args_i and tool_i < args_i, | |
| "tool at " .. tostring(tool_i) .. ", args at " .. tostring(args_i)) | |
| -- The overwhelmingly most common first failure: model not pulled. | |
| local p_ok, p_err = pcall(ollama.parse_response, | |
| '{"error":"model \'qwen2.5:0.5b-instruct\' not found, try pulling it first"}', | |
| "qwen2.5:0.5b-instruct") | |
| check("unpulled model raises", not p_ok) | |
| check("and says how to fix it", | |
| tostring(p_err):match("ollama pull") ~= nil, p_err) | |
| -- Any other Ollama error. | |
| local g_ok, g_err = pcall(ollama.parse_response, '{"error":"out of memory"}', "m") | |
| check("generic error raises", not g_ok) | |
| check("generic error keeps the message", | |
| tostring(g_err):match("out of memory") ~= nil, g_err) | |
| -- Daemon down: curl writes nothing. | |
| local d_ok = pcall(ollama.parse_response, "", "m") | |
| check("empty body raises rather than returning junk", not d_ok) | |
| -- A well-formed response with no response field. | |
| local n_ok = pcall(ollama.parse_response, '{"done":true}', "m") | |
| check("missing response field raises", not n_ok) | |
| end | |
| end | |
| -------------------------------------------------------------------------- | |
| group("mcp") | |
| -------------------------------------------------------------------------- | |
| -- No server, no npx, no network. The fixtures are real captures from | |
| -- @modelcontextprotocol/server-everything, including the two things the | |
| -- spec does not lead with: responses arrive out of order with | |
| -- notifications mixed in, and a failing tool returns a SUCCESSFUL result | |
| -- carrying isError rather than a JSON-RPC error. | |
| do | |
| local ok_mcp, mcp = pcall(require, "mcp") | |
| if ok_mcp and mcp.new and ok_tools then | |
| local sent | |
| local function transport(reply, ok) | |
| return function(request) sent = request return ok ~= false, reply, 0 end | |
| end | |
| -- Deliberately out of order: notification first, then tools/list | |
| -- (id 2), then initialize (id 1). Position-based matching passes a | |
| -- naive test and fails against the real server. | |
| local discovery = table.concat({ | |
| '{"method":"notifications/tools/list_changed","jsonrpc":"2.0"}', | |
| '{"jsonrpc":"2.0","id":2,"result":{"tools":[' .. | |
| '{"name":"echo","description":"Echoes back the input string",' .. | |
| '"inputSchema":{"type":"object","properties":{"message":{"type":"string"}},' .. | |
| '"required":["message"]}},' .. | |
| '{"name":"add","description":"Adds two numbers",' .. | |
| '"inputSchema":{"type":"object","properties":{"a":{"type":"number"},' .. | |
| '"b":{"type":"integer"}},"required":["a","b"]}}]}}', | |
| '{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-06-18",' .. | |
| '"serverInfo":{"name":"mcp-servers/everything","version":"2.0.0"}}}', | |
| }, "\n") | |
| local c = mcp.new({ transport = transport(discovery) }) | |
| local list = c:discover() | |
| eq("discovers every tool", #list, 2) | |
| eq("matches initialize by id, not position", | |
| c.server_info and c.server_info.name, "mcp-servers/everything") | |
| eq("records the negotiated version", c.negotiated_version, "2025-06-18") | |
| local req = sent or "" | |
| check("handshake sends initialize", req:match('"initialize"') ~= nil) | |
| check("handshake sends the initialized notification", | |
| req:match("notifications/initialized") ~= nil) | |
| check("and asks for tools/list", req:match('"tools/list"') ~= nil) | |
| check("requests are newline-delimited JSON", | |
| select(2, req:gsub("\n", "")) >= 3, req) | |
| -- Schema conversion into the registry's arg format. | |
| local reg = tools.registry() | |
| reg:add(tools.calc) | |
| local mounted = mcp.mount(reg, c, { prefix = "mcp_" }) | |
| eq("mounts all of them", #mounted, 2) | |
| check("namespaced by the prefix", reg:get("mcp_echo") ~= nil) | |
| check("built-ins untouched", reg:get("calc") ~= nil) | |
| local echo = reg:get("mcp_echo") | |
| eq("required string argument survives", echo.args[1].name, "message") | |
| eq("typed as a string", echo.args[1].type, "string") | |
| eq("and marked required", echo.args[1].required, true) | |
| -- Remote code runs outside this process; it does not get the | |
| -- unattended treatment read-only built-ins get. | |
| eq("remote tools need approval by default", echo.requires_approval, true) | |
| local add = reg:get("mcp_add") | |
| eq("JSON Schema integer maps to number", add.args[1].type, "number") | |
| eq("args are sorted for a stable grammar", add.args[1].name, "a") | |
| -- Collisions are refused, not silently resolved: shadowing calc | |
| -- with a remote tool of the same name would be a nasty surprise. | |
| local reg2 = tools.registry() | |
| reg2:add(tools.calc) | |
| local clash = mcp.new({ transport = transport(discovery) }) | |
| clash:discover() | |
| clash.tools[1].name = "calc" | |
| local cok, cerr = pcall(mcp.mount, reg2, clash, {}) | |
| check("refuses to shadow an existing tool", not cok) | |
| check("and says to use a prefix", | |
| tostring(cerr):match("prefix") ~= nil, cerr) | |
| -- A failing tool: successful JSON-RPC, isError true. | |
| local c2 = mcp.new({ transport = transport(table.concat({ | |
| '{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-06-18","serverInfo":{}}}', | |
| '{"jsonrpc":"2.0","id":2,"result":{"content":[{"type":"text",' .. | |
| '"text":"MCP error -32602: Invalid input: expected string"}],"isError":true}}', | |
| }, "\n")) }) | |
| local txt, is_err = c2:call("echo", {}) | |
| eq("tool failure is reported, not raised", is_err, true) | |
| check("and keeps the message the model needs", | |
| txt:match("expected string") ~= nil, txt) | |
| -- Non-text content is named rather than dropped silently. | |
| local c3 = mcp.new({ transport = transport(table.concat({ | |
| '{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"x","serverInfo":{}}}', | |
| '{"jsonrpc":"2.0","id":2,"result":{"content":[{"type":"image","data":"..."}]}}', | |
| }, "\n")) }) | |
| local itxt = c3:call("shot", {}) | |
| check("non-text content is named", itxt:match("image") ~= nil, itxt) | |
| -- A real JSON-RPC error object, which IS a failure. | |
| local c4 = mcp.new({ transport = transport( | |
| '{"jsonrpc":"2.0","id":1,"error":{"code":-32601,"message":"Method not found"}}') }) | |
| local eok, eerr = pcall(function() return c4:discover() end) | |
| check("a JSON-RPC error raises", not eok) | |
| check("with the server's message", | |
| tostring(eerr):match("Method not found") ~= nil, eerr) | |
| -- Server that never started: stdout empty. | |
| local c5 = mcp.new({ transport = transport("", false) }) | |
| local sok = pcall(function() return c5:discover() end) | |
| check("a dead server raises rather than hanging", not sok) | |
| -- Streamable HTTP replies are SSE frames, not bare JSON -- even for | |
| -- a single response. Captured from server-everything. | |
| local sse = table.concat({ | |
| "event: message", | |
| "id: c93c36e5-2a40-47fe-add0-8295cae3aa4f", | |
| 'data: {"result":{"protocolVersion":"2025-06-18","serverInfo":' .. | |
| '{"name":"mcp-servers/everything"}},"jsonrpc":"2.0","id":1}', | |
| }, "\n") | |
| local c7 = mcp.new({ transport = transport(sse) }) | |
| local by_id = c7:exchange({ { jsonrpc = "2.0", id = 1, method = "initialize" } }) | |
| check("unwraps SSE data: frames", by_id[1] ~= nil) | |
| eq("and reads through to the payload", | |
| by_id[1] and by_id[1].result.serverInfo.name, "mcp-servers/everything") | |
| -- Over HTTP the handshake happens once and the session id carries | |
| -- it; over stdio the server is re-spawned, so every exchange must | |
| -- re-handshake. Getting this backwards either wastes a round trip | |
| -- per call or talks to an uninitialised server. | |
| local h = mcp.new({ url = "http://localhost:1/mcp" }) | |
| eq("http handshakes on the first exchange", #h:handshake(), 2) | |
| h.session_ready = true | |
| eq("and not again afterwards", #h:handshake(), 0) | |
| local st = mcp.new({ command = "true" }) | |
| st.session_ready = true | |
| eq("stdio always re-handshakes", #st:handshake(), 2) | |
| -- Garbage on stdout must not kill an otherwise good exchange. | |
| local c6 = mcp.new({ transport = transport(table.concat({ | |
| "Starting default (STDIO) server...", | |
| '{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"1","serverInfo":{}}}', | |
| '{"jsonrpc":"2.0","id":2,"result":{"tools":[]}}', | |
| }, "\n")) }) | |
| local gok = pcall(function() return c6:discover() end) | |
| check("non-JSON noise on stdout is skipped", gok) | |
| end | |
| end | |
| -------------------------------------------------------------------------- | |
| group("replay") | |
| -------------------------------------------------------------------------- | |
| -- Every transcript in examples/ is backed by a trace in traces/. Replaying | |
| -- one must reproduce the recorded answer exactly. This is what makes the | |
| -- examples evidence rather than illustration: if a transcript stops | |
| -- reproducing, this fails. | |
| -- | |
| -- Skipped silently when the traces are absent (regenerate: lua54 record.lua). | |
| do | |
| local ok_replay, replay = pcall(require, "backend.replay") | |
| local ok_tasks, tasklist = pcall(require, "eval.tasks.tasks") | |
| local ok_proto, proto = pcall(require, "protocol") | |
| if ok_replay and ok_tasks and ok_proto and ok_tools and ok_agent then | |
| local real_render = proto.render_system | |
| proto.render_system = function(t, task) return real_render(t, task, true) end | |
| local approx = { encode = function(_, s) | |
| local o = {} | |
| for k = 1, math.max(1, math.ceil(#s / 4)) do o[k] = k end | |
| return o | |
| end } | |
| local found = 0 | |
| for _, task in ipairs(tasklist.tasks) do | |
| local path = "traces/" .. task.id .. ".jsonl" | |
| local fh = io.open(path, "r") | |
| if fh then | |
| fh:close() | |
| found = found + 1 | |
| local reg = tools.registry() | |
| for _, n in ipairs(task.tools) do reg:add(tools[n]) end | |
| local a = Agent.new({ | |
| backend = replay.new({ path = path }), | |
| tools = reg, | |
| tokenizer = approx, | |
| max_steps = 10, | |
| max_tokens = task.budget or 4096, | |
| approve = function() return task.approve == true end, | |
| }) | |
| local ran, answer, _, steps = pcall(function() | |
| local x, y, z = a:run(task.prompt) | |
| return x, y, z | |
| end) | |
| check("replays " .. task.id, ran, answer) | |
| if ran then | |
| local passed, why = task.check(answer, steps or {}, { evicted = true }) | |
| check("replay of " .. task.id .. " still passes its check", | |
| passed, why) | |
| end | |
| end | |
| end | |
| proto.render_system = real_render | |
| if found == 0 then | |
| io.write(" (no traces on disk; run: lua54 record.lua)\n") | |
| end | |
| end | |
| end | |
| -------------------------------------------------------------------------- | |
| io.write(string.format("\n%d passed, %d failed\n", pass, fail)) | |
| os.exit(fail == 0 and 0 or 1) | |