Spaces:
Running on Zero
Running on Zero
File size: 40,271 Bytes
98acb70 | 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 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 | -- 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)
|