//! Lua C-API bridge for the panto CLI. //! //! Exposes a `panto` table inside any `lua_State` we construct, reachable //! from Lua via `require('panto')` (registered into `package.preload` — //! there is no `panto` global). Internal Zig code reaches the same table //! through the registry via `pushPantoTable`. //! All extension-authoring APIs live under the `panto.ext` subtable; the //! bare `panto` namespace is reserved for the future full libpanto API //! surface, so extensions never collide with it. The current `panto.ext` //! members are: //! //! panto.ext.register_tool { //! name = "...", //! description = "...", //! schema = { ... }, -- JSON Schema as a Lua table //! handler = function(input) ... end, //! } //! panto.ext.register_command { name=, description=, handler= } //! panto.ext.on(event_name, function(event) ... end) -- §7 UI events //! panto.ext.emit(event_name, data_table) -- fire a custom event //! //! The single-table-argument form is idiomatic Lua "named arguments". //! It's also forward-compatible: future optional fields (examples, //! version, etc.) can be added without breaking existing extensions. //! //! Each call records a registration in a Lua-side table at a fixed //! registry slot (`registrations_key`). The Zig side reads that table //! to decide what to do with the entries: //! //! - The long-lived `LuaRuntime` (`src/lua_runtime.zig`) loads each //! extension into a single `lua_State`, then walks the registrations //! table once per loaded script (between loads it calls //! `resetRegistrations`). For each entry it copies name / //! description / schema, `luaL_ref`s the handler function into the //! Lua registry, and records the ref under the tool name. //! //! - On dispatch, the runtime spawns a coroutine, pushes the handler //! onto it via `lua_rawgeti(LUA_REGISTRYINDEX, ref)`, pushes the //! parsed JSON input, and `lua_resume`s. Top-level extension code //! never runs again — only handler bodies. //! //! This bridge module itself is stateless beyond the public //! `registrations_key` address; it cooperates with whatever runtime //! owns the `lua_State`. const std = @import("std"); const Allocator = std.mem.Allocator; const panto = @import("panto"); pub const c = @cImport({ @cInclude("lua.h"); @cInclude("lauxlib.h"); @cInclude("lualib.h"); }); // Lua type constants are #defines that translate_c surfaces as inline // functions returning the literal; using them in switch prongs needs // explicit `c_int` constants. Define our own clean aliases. pub const T_NIL: c_int = 0; pub const T_BOOLEAN: c_int = 1; pub const T_NUMBER: c_int = 3; pub const T_STRING: c_int = 4; pub const T_TABLE: c_int = 5; pub const T_FUNCTION: c_int = 6; pub const T_USERDATA: c_int = 7; pub const LUA_MULTRET: c_int = -1; pub const LUA_REGISTRYINDEX: c_int = -1001000; // matches lua.h with LUAI_MAXSTACK=1000000 /// Errors the bridge can produce when talking to a Lua state. pub const BridgeError = error{ LuaInitFailed, LuaLoadFailed, LuaRunFailed, LuaHandlerCrashed, LuaHandlerNotFound, BadRegistration, BadHandlerReturn, InputNotJsonObject, OutOfMemory, }; /// The key under which we stash the registrations table in /// `LUA_REGISTRYINDEX`. Any unique pointer works — we use the address of a /// module-level `u8` so multiple states all use the same key value. /// /// Public so the long-lived runtime can poke at it directly when it /// needs to harvest entries. pub var registrations_key: u8 = 0; /// The key under which we stash the *command* registrations table in /// `LUA_REGISTRYINDEX`. Distinct from `registrations_key` so tool and /// slash-command registrations never collide. Holds an array of records /// shaped `{ name=, description=, handler= }`. pub var command_registrations_key: u8 = 0; /// The key under which we stash the *event-handler* (`panto.ext.on`) /// registrations table in `LUA_REGISTRYINDEX`. Holds an array of records /// shaped `{ event=, handler= }`, in registration order (§7.3). The /// runtime harvests these and registers each as a native `EventBus` /// handler that bridges back into Lua. pub var on_registrations_key: u8 = 0; /// The key under which we stash the canonical `panto` module table in /// `LUA_REGISTRYINDEX`. Internal Zig setup (`installEmit`, the runtime's /// `_record_result` / wrapper / register_tool paths) fetches it via /// `pushPantoTable` instead of a global lookup — `panto` is not a global. /// /// This slot is populated by the runtime's `installPantoModule`, which /// obtains the **native** `panto` table via `require('panto')` (resolving /// the staged `panto.so` on `cpath`) and attaches `ext` to it. Until then /// the slot is empty and `pushPantoTable` pushes `nil`; nothing fetches it /// before module load. pub var panto_table_key: u8 = 0; /// The key under which we stash the `panto.ext` extension subtable in /// `LUA_REGISTRYINDEX`. Built by `install` (before any native module /// exists) so the host can attach it to the native `panto` table once /// `require('panto')` resolves. Distinct from `panto_table_key` because /// `ext` is constructed first and outlives the module-load step. pub var ext_table_key: u8 = 0; /// A single declared tool, as harvested from a script's top-level call to /// `panto.ext.register_tool`. All slices reference Lua-owned strings on the /// state's stack/registry; copy them before closing the state. pub const Registration = struct { name: []const u8, description: []const u8, /// Serialized JSON Schema for the tool's input. schema_json: []const u8, }; /// A single declared slash command, harvested from a script's top-level /// call to `panto.ext.register_command`. Slices reference Lua-owned strings; /// copy them before closing the state. pub const CommandRegistration = struct { name: []const u8, description: []const u8, }; /// A single `panto.ext.on(event, handler)` registration, harvested from a /// script. `event` references a Lua-owned string (copy before closing the /// state); the handler function is `luaL_ref`'d separately by the runtime. pub const OnRegistration = struct { event: []const u8, }; // --------------------------------------------------------------------------- // Public bridge API // --------------------------------------------------------------------------- /// Push the canonical `panto` module table onto the stack from its /// registry slot. The slot is populated by the runtime's /// `installPantoModule` (which `require`s the native module and attaches /// `ext`); before that runs the slot is empty and this pushes `nil`. /// Internal Zig setup uses this instead of a global lookup, since `panto` /// is not a global — it is delivered to Lua only through `require('panto')`. pub fn pushPantoTable(L: *c.lua_State) void { _ = c.lua_rawgetp(L, LUA_REGISTRYINDEX, &panto_table_key); } /// Push the `panto.ext` extension subtable from its registry slot. Built /// by `install`, so it is available before the native module loads (the /// host attaches it to the native table in `installPantoModule`). pub fn pushExtTable(L: *c.lua_State) void { _ = c.lua_rawgetp(L, LUA_REGISTRYINDEX, &ext_table_key); } /// Stash a `panto` module table (on the stack top) into `panto_table_key`. /// Called by `installPantoModule` after it builds the native+`ext` table. /// Does NOT pop — leaves the table on the stack for the caller. pub fn setPantoTable(L: *c.lua_State) void { c.lua_pushvalue(L, -1); // dup for the registry slot c.lua_rawsetp(L, LUA_REGISTRYINDEX, &panto_table_key); } /// Register a `package.preload['panto']` loader returning the `panto` /// module table currently on the stack top. Does NOT pop the table — /// leaves it in place for the caller. The loader returns the table stashed /// under `panto_table_key`, so it must be called together with /// `setPantoTable` (the runtime does both in `installPantoModule`). pub fn installPantoPreload(L: *c.lua_State) void { _ = c.lua_getglobal(L, "package"); _ = c.lua_getfield(L, -1, "preload"); c.lua_pushcclosure(L, pantoPreloadThunk, 0); c.lua_setfield(L, -2, "panto"); // preload.panto = thunk c.lua_settop(L, c.lua_gettop(L) - 2); // pop preload + package } /// The `package.preload['panto']` loader: returns the augmented `panto` /// table from `panto_table_key`. Standard preload-loader contract: one /// return value. Any `require('panto')` after `installPantoModule` — from /// an extension or otherwise — routes here (preload is searcher slot 1). fn pantoPreloadThunk(L_opt: ?*c.lua_State) callconv(.c) c_int { const L = L_opt.?; pushPantoTable(L); return 1; } /// Build the `panto.ext` extension subtable and the registry tables that /// hold harvested tool/command/event-handler registrations. /// /// This does **not** build the `panto` module table and does **not** touch /// `package.preload` — the `panto` table comes from the native /// `libpanto-lua` module via `require('panto')`, wired by the runtime's /// `installPantoModule` once the staged `panto.so` is on `cpath`. Here we /// only construct `ext` (stashed under `ext_table_key`) so the host can /// attach it to that native table later. /// /// All extension-authoring APIs (`register_tool`, `register_command`, /// `on`, `emit`) live under `panto.ext`. `emit` is installed here as a /// safe no-op default; the long-lived runtime overrides it with a closure /// carrying its context via `installEmit` once the module table exists. pub fn install(L: *c.lua_State) void { // Create the registrations table: an array of records, each shaped // { name=, description=, schema_json=, handler= }. c.lua_createtable(L, 0, 0); // Stash under our registry key. c.lua_rawsetp(L, LUA_REGISTRYINDEX, ®istrations_key); // Create the command registrations table (parallel to the tool one). c.lua_createtable(L, 0, 0); c.lua_rawsetp(L, LUA_REGISTRYINDEX, &command_registrations_key); // Create the `panto.ext.on` registrations table. c.lua_createtable(L, 0, 0); c.lua_rawsetp(L, LUA_REGISTRYINDEX, &on_registrations_key); // Build the `panto.ext` subtable and stash it for later attachment. c.lua_createtable(L, 0, 4); // panto.ext c.lua_pushcclosure(L, registerToolThunk, 0); c.lua_setfield(L, -2, "register_tool"); c.lua_pushcclosure(L, registerCommandThunk, 0); c.lua_setfield(L, -2, "register_command"); c.lua_pushcclosure(L, registerOnThunk, 0); c.lua_setfield(L, -2, "on"); // Default `emit`: a no-op until the runtime installs the real one. c.lua_pushcclosure(L, emitNoopThunk, 0); c.lua_setfield(L, -2, "emit"); // `panto.ext.json`: the JSON codec the platform already uses for tool // inputs and schemas, exposed so extensions can decode event payloads // (e.g. `tool_result.input`) and build `tool_call_complete.input` // overrides without a rock dependency. c.lua_createtable(L, 0, 2); c.lua_pushcclosure(L, jsonDecodeThunk, 0); c.lua_setfield(L, -2, "decode"); c.lua_pushcclosure(L, jsonEncodeThunk, 0); c.lua_setfield(L, -2, "encode"); c.lua_setfield(L, -2, "json"); // Stash `ext` under its registry key, consuming it. c.lua_rawsetp(L, LUA_REGISTRYINDEX, &ext_table_key); } /// `panto.ext.json.decode(str)` -> Lua value. Accepts any top-level JSON /// value; JSON `null` decodes to Lua `nil` (documented lossiness for null /// object values). fn jsonDecodeThunk(L_opt: ?*c.lua_State) callconv(.c) c_int { const L = L_opt.?; var len: usize = 0; const ptr = c.luaL_checklstring(L, 1, &len); var parsed = std.json.parseFromSlice(std.json.Value, std.heap.c_allocator, ptr[0..len], .{}) catch { return c.luaL_error(L, "json.decode: invalid JSON"); }; defer parsed.deinit(); pushJsonValue(L, parsed.value) catch return c.luaL_error(L, "json.decode: out of memory"); return 1; } /// `panto.ext.json.encode(value)` -> JSON string. Same value domain as tool /// schemas: strings, numbers, booleans, nil, array- or object-shaped tables. fn jsonEncodeThunk(L_opt: ?*c.lua_State) callconv(.c) c_int { const L = L_opt.?; pushValueAsJson(L, 1) catch return c.luaL_error(L, "json.encode: value is not JSON-serializable"); return 1; } /// Override `panto.ext.emit` with a closure that carries `ctx` as a /// light-userdata upvalue and dispatches into `emit_fn`. The runtime calls /// this after `install` so a Lua `emit(name, data)` reaches the native /// `EventBus`. Until then, `emit` is a no-op (see `install`). /// /// `emit_fn` runs with the Lua stack holding `(name_string, data_table?)` /// as args 1 and 2; it is a plain C function whose first upvalue is the /// light-userdata `ctx`. pub fn installEmit( L: *c.lua_State, ctx: *anyopaque, emit_fn: *const fn (L_opt: ?*c.lua_State) callconv(.c) c_int, ) void { pushExtTable(L); c.lua_pushlightuserdata(L, ctx); c.lua_pushcclosure(L, emit_fn, 1); c.lua_setfield(L, -2, "emit"); c.lua_settop(L, c.lua_gettop(L) - 1); // pop ext } /// The default `panto.ext.emit`: a no-op. Replaced by `installEmit` once /// the runtime is ready. fn emitNoopThunk(L_opt: ?*c.lua_State) callconv(.c) c_int { _ = L_opt; return 0; } // --------------------------------------------------------------------------- // User-Lua entrypoints: "you are always in a panto-managed uv loop" // --------------------------------------------------------------------------- /// True while a panto-driven `uv.run` is executing on the shared state — /// set by the tool scheduler's batch driver and by `runEntrypoint`'s own /// pump. Consulted by nested dispatches (e.g. `panto.ext.emit` fired from /// inside already-running Lua): the loop is not re-entrant, so a nested /// entrypoint must complete without awaiting. pub var uv_loop_active: bool = false; /// Registry key for the memoized stash-wrapper function (see /// `pushStashWrapper`). var entry_wrapper_key: u8 = 0; /// Registry key for the result stash: a weak-keyed table mapping a /// coroutine thread -> `table.pack(...)` of its entrypoint's results. var entry_results_key: u8 = 0; /// Run a user-provided Lua entrypoint — an extension `activate()`, an /// `on` event handler, a slash-command handler, a component method — /// under the uniform contract: the code runs inside a coroutine, and the /// libuv loop is drained before returning. User Lua may therefore always /// await callback-form uv operations (arm, yield, resume-from-callback), /// exactly as in tool handlers, and any fire-and-forget coroutines it /// forks are also run to completion; no entrypoint needs to know whether /// a loop "is already there". /// /// Expects the function and its `nargs` arguments on top of `L`; /// consumes them. Return values are discarded (use `runEntrypointResult` /// to keep one). Synchronous from the caller's view: returns only once /// the entrypoint has terminated, so registration-ordered handler chains /// and the component get/set pattern keep their sequencing. /// /// On failure the error text (with traceback where available) is copied /// into `err_buf` when provided (`err_len.*` = bytes written, truncated) /// and logged otherwise. pub fn runEntrypoint( L: *c.lua_State, nargs: c_int, label: []const u8, err_buf: ?[]u8, err_len: ?*usize, ) error{LuaRunFailed}!void { return runEntrypointInner(L, nargs, false, label, err_buf, err_len); } /// `runEntrypoint`, keeping the entrypoint's first return value: on /// success exactly one value (the result, or nil if it returned nothing) /// is left on top of `L`. Results are captured *inside* the coroutine (a /// wrapper stashes them keyed by thread), so they survive completion via /// a uv-callback resume — where a coroutine's return values flow to the /// resuming callback, not to us. pub fn runEntrypointResult( L: *c.lua_State, nargs: c_int, label: []const u8, err_buf: ?[]u8, err_len: ?*usize, ) error{LuaRunFailed}!void { return runEntrypointInner(L, nargs, true, label, err_buf, err_len); } fn runEntrypointInner( L: *c.lua_State, nargs: c_int, want_result: bool, label: []const u8, err_buf: ?[]u8, err_len: ?*usize, ) error{LuaRunFailed}!void { const base = c.lua_gettop(L) - nargs - 1; // index below the fn errdefer c.lua_settop(L, base); // With a result requested, run `wrapper(fn, args...)` instead of // `fn(args...)`; the wrapper stashes `table.pack(fn(...))` under the // running thread before the coroutine terminates. var total_args = nargs; if (want_result) { pushStashWrapper(L) catch { reportEntrypointFailure(label, "failed to build the result wrapper", err_buf, err_len); return error.LuaRunFailed; }; c.lua_rotate(L, base + 1, 1); // wrapper beneath fn: wrapper, fn, args... total_args += 1; } const co_ptr = c.lua_newthread(L) orelse { reportEntrypointFailure(label, "out of memory creating coroutine", err_buf, err_len); return error.LuaRunFailed; }; const co: *c.lua_State = @ptrCast(co_ptr); // Stack: [wrapper,] fn, args..., co. Rotate the thread to the bottom so // it stays anchored on `L` (GC-safe) while the callable+args move over. c.lua_rotate(L, base + 1, 1); c.lua_xmove(L, co, total_args + 1); var nres: c_int = 0; const first = c.lua_resume(co, L, total_args, &nres); if (uv_loop_active) { // An outer frame is already driving the loop and uv.run is not // re-entrant, so a nested entrypoint (e.g. one reached via `emit` // from running Lua) cannot wait here. It must complete on its // first resume; anything it armed is picked up by the outer drain. if (first == c.LUA_YIELD) { reportEntrypointFailure( label, "yielded during nested dispatch (the uv loop is already " ++ "running); an entrypoint invoked from running Lua " ++ "(e.g. via emit) must not await", err_buf, err_len, ); return error.LuaRunFailed; } } else { // Drain the loop completely — unconditionally, even when the // first resume already finished. The entrypoint may have forked // fire-and-forget coroutines whose armed callbacks would // otherwise never run; nothing user Lua started may be left // hanging when we return. (Corollary of the contract: an // entrypoint must not leave long-lived handles armed — a // repeating timer would hold this drain open forever, exactly as // it would the tool batch's.) drainLoop(L) catch { // A handler error surfacing through a uv callback propagates // out of uv.run; its message is on top of L. reportErrorAtTop(L, label, err_buf, err_len); return error.LuaRunFailed; }; } const status = c.lua_status(co); if (status == c.LUA_YIELD) { reportEntrypointFailure( label, "suspended after the uv loop drained: it yielded without a " ++ "pending libuv operation to resume it", err_buf, err_len, ); return error.LuaRunFailed; } if (status != c.LUA_OK) { // Error object on the coroutine's stack; render with traceback. var mlen: usize = 0; const mptr = c.lua_tolstring(co, -1, &mlen); c.luaL_traceback(L, co, mptr, 0); reportErrorAtTop(L, label, err_buf, err_len); return error.LuaRunFailed; } if (!want_result) { c.lua_settop(L, base); return; } // Fetch (and clear) this thread's stashed result pack. _ = c.lua_rawgetp(L, LUA_REGISTRYINDEX, &entry_results_key); if (c.lua_type(L, -1) != T_TABLE) { reportEntrypointFailure(label, "no result was recorded", err_buf, err_len); return error.LuaRunFailed; } c.lua_pushvalue(L, base + 1); // the thread (anchored at base+1) _ = c.lua_rawget(L, -2); // Stack: ..., stash, packed|nil c.lua_pushvalue(L, base + 1); c.lua_pushnil(L); c.lua_rawset(L, -4); // stash[thread] = nil if (c.lua_type(L, -1) != T_TABLE) { reportEntrypointFailure(label, "no result was recorded", err_buf, err_len); return error.LuaRunFailed; } _ = c.lua_rawgeti(L, -1, 1); // first result (nil if it returned none) // Stack: base..., co, stash, packed, result → keep only the result. c.lua_rotate(L, base + 1, 1); c.lua_settop(L, base + 1); } /// Push the memoized stash wrapper `function(fn, ...)` that runs `fn` and /// records `table.pack(fn(...))` in the result stash keyed by the running /// thread. The stash has weak keys so an abandoned thread's entry does /// not pin it. fn pushStashWrapper(L: *c.lua_State) !void { _ = c.lua_rawgetp(L, LUA_REGISTRYINDEX, &entry_wrapper_key); if (c.lua_type(L, -1) == T_FUNCTION) return; c.lua_settop(L, c.lua_gettop(L) - 1); // pop nil // Ensure the stash table exists. _ = c.lua_rawgetp(L, LUA_REGISTRYINDEX, &entry_results_key); if (c.lua_type(L, -1) != T_TABLE) { c.lua_settop(L, c.lua_gettop(L) - 1); // pop nil c.lua_createtable(L, 0, 2); c.lua_createtable(L, 0, 1); // its metatable _ = c.lua_pushlstring(L, "k", 1); c.lua_setfield(L, -2, "__mode"); // weak keys _ = c.lua_setmetatable(L, -2); c.lua_pushvalue(L, -1); c.lua_rawsetp(L, LUA_REGISTRYINDEX, &entry_results_key); } // Stack: ..., stash. Compile the wrapper factory and apply it. const chunk = "local stash = ...\n" ++ "return function(fn, ...)\n" ++ " stash[coroutine.running()] = table.pack(fn(...))\n" ++ "end\n"; if (c.luaL_loadstring(L, chunk) != 0) { c.lua_settop(L, c.lua_gettop(L) - 2); // pop error + stash return error.LuaLoadFailed; } c.lua_rotate(L, -2, 1); // chunk beneath stash if (c.lua_pcallk(L, 1, 1, 0, 0, null) != 0) { c.lua_settop(L, c.lua_gettop(L) - 1); // pop error return error.LuaRunFailed; } // Stack: ..., wrapper. Memoize and leave it on top. c.lua_pushvalue(L, -1); c.lua_rawsetp(L, LUA_REGISTRYINDEX, &entry_wrapper_key); } /// `require("luv").run("default")`: run the loop until no active handles /// or requests remain, marking it panto-driven for the duration (nested /// entrypoints consult `uv_loop_active`). Returns immediately on an idle /// loop. When luv is not available (unit-test builds don't link it) there /// is no loop and nothing could have armed work on one — the drain is a /// no-op. On a `uv.run` error the Lua message is left on top of `L`. /// Shared by `runEntrypoint` and the tool scheduler's batch driver. pub fn drainLoop(L: *c.lua_State) !void { uv_loop_active = true; defer uv_loop_active = false; _ = c.lua_getglobal(L, "require"); _ = c.lua_pushlstring(L, "luv", 3); if (c.lua_pcallk(L, 1, 1, 0, 0, null) != 0) { c.lua_settop(L, c.lua_gettop(L) - 1); // pop the require error return; } _ = c.lua_getfield(L, -1, "run"); c.lua_copy(L, -1, -2); c.lua_settop(L, c.lua_gettop(L) - 1); // Stack: ..., run _ = c.lua_pushlstring(L, "default", 7); if (c.lua_pcallk(L, 1, 1, 0, 0, null) != 0) return error.UvRunFailed; c.lua_settop(L, c.lua_gettop(L) - 1); // discard the boolean } /// Deliver an entrypoint failure: copy into the caller's buffer when one /// was provided, else log (warn under test — the test runner fails any /// test that logs at error level, and several tests exercise failures). fn reportEntrypointFailure( label: []const u8, msg: []const u8, err_buf: ?[]u8, err_len: ?*usize, ) void { if (err_buf) |buf| { const n = @min(msg.len, buf.len); @memcpy(buf[0..n], msg[0..n]); if (err_len) |lp| lp.* = n; return; } if (@import("builtin").is_test) { std.log.warn("lua: '{s}' entrypoint failed: {s}", .{ label, msg }); } else { std.log.err("lua: '{s}' entrypoint failed: {s}", .{ label, msg }); } } /// Like `reportEntrypointFailure`, with the message taken from the value /// at the top of `L` (not popped; the caller's stack cleanup handles it). fn reportErrorAtTop( L: *c.lua_State, label: []const u8, err_buf: ?[]u8, err_len: ?*usize, ) void { var len: usize = 0; const ptr = c.lua_tolstring(L, -1, &len); const msg: []const u8 = if (ptr != null) ptr[0..len] else "(no error message)"; reportEntrypointFailure(label, msg, err_buf, err_len); } /// Replace the registrations table with a fresh empty one. Used by the /// long-lived runtime between loading distinct extension scripts so it /// can harvest only the registrations made by the script just loaded /// (not accumulated from prior loads). pub fn resetRegistrations(L: *c.lua_State) void { c.lua_createtable(L, 0, 0); c.lua_rawsetp(L, LUA_REGISTRYINDEX, ®istrations_key); c.lua_createtable(L, 0, 0); c.lua_rawsetp(L, LUA_REGISTRYINDEX, &command_registrations_key); c.lua_createtable(L, 0, 0); c.lua_rawsetp(L, LUA_REGISTRYINDEX, &on_registrations_key); } /// Load and execute a Lua source file in the given state. The file's /// top-level code typically calls `panto.ext.register_tool(...)` one or more /// times, populating the registrations table. /// /// On Lua error, the error message is left on the stack — callers that /// want to surface it can read the top with `lua_tolstring`. pub fn loadFile(L: *c.lua_State, path: [:0]const u8) BridgeError!void { if (c.luaL_loadfilex(L, path.ptr, null) != 0) return BridgeError.LuaLoadFailed; if (c.lua_pcallk(L, 0, 0, 0, 0, null) != 0) return BridgeError.LuaRunFailed; } /// Walk the registrations table and copy each entry's name, description, /// and schema_json into freshly-allocated bytes owned by `arena`. The /// returned slice (and every byte slice inside each `Registration`) lives /// as long as the arena does. /// /// The handler field is ignored — discovery mode doesn't care about it. pub fn harvestRegistrations( L: *c.lua_State, arena: Allocator, ) BridgeError![]Registration { // Push the registrations table. _ = c.lua_rawgetp(L, LUA_REGISTRYINDEX, ®istrations_key); defer c.lua_settop(L, c.lua_gettop(L) - 1); // pop the table when done const n: usize = @intCast(c.lua_rawlen(L, -1)); if (n == 0) return arena.alloc(Registration, 0) catch BridgeError.OutOfMemory; var out = arena.alloc(Registration, n) catch return BridgeError.OutOfMemory; var i: usize = 1; while (i <= n) : (i += 1) { _ = c.lua_rawgeti(L, -1, @intCast(i)); // record table on top defer c.lua_settop(L, c.lua_gettop(L) - 1); const name = try readStringField(L, -1, "name", arena); const desc = try readStringField(L, -1, "description", arena); const schema = try readStringField(L, -1, "schema_json", arena); out[i - 1] = .{ .name = name, .description = desc, .schema_json = schema, }; } return out; } /// Walk the *command* registrations table and copy each entry's name and /// description into arena-owned bytes. The handler field is ignored; /// callers that need to invoke handlers `luaL_ref` them separately. pub fn harvestCommandRegistrations( L: *c.lua_State, arena: Allocator, ) BridgeError![]CommandRegistration { _ = c.lua_rawgetp(L, LUA_REGISTRYINDEX, &command_registrations_key); defer c.lua_settop(L, c.lua_gettop(L) - 1); const n: usize = @intCast(c.lua_rawlen(L, -1)); if (n == 0) return arena.alloc(CommandRegistration, 0) catch BridgeError.OutOfMemory; var out = arena.alloc(CommandRegistration, n) catch return BridgeError.OutOfMemory; var i: usize = 1; while (i <= n) : (i += 1) { _ = c.lua_rawgeti(L, -1, @intCast(i)); defer c.lua_settop(L, c.lua_gettop(L) - 1); out[i - 1] = .{ .name = try readStringField(L, -1, "name", arena), .description = try readStringField(L, -1, "description", arena), }; } return out; } /// Walk the `panto.ext.on` registrations table and copy each entry's event /// name into arena-owned bytes, in registration order. The handler /// function is ignored here; the runtime `luaL_ref`s handlers separately /// (it needs to keep the ref alive in the live state, which the arena /// cannot do). pub fn harvestOnRegistrations( L: *c.lua_State, arena: Allocator, ) BridgeError![]OnRegistration { _ = c.lua_rawgetp(L, LUA_REGISTRYINDEX, &on_registrations_key); defer c.lua_settop(L, c.lua_gettop(L) - 1); const n: usize = @intCast(c.lua_rawlen(L, -1)); if (n == 0) return arena.alloc(OnRegistration, 0) catch BridgeError.OutOfMemory; var out = arena.alloc(OnRegistration, n) catch return BridgeError.OutOfMemory; var i: usize = 1; while (i <= n) : (i += 1) { _ = c.lua_rawgeti(L, -1, @intCast(i)); defer c.lua_settop(L, c.lua_gettop(L) - 1); out[i - 1] = .{ .event = try readStringField(L, -1, "event", arena) }; } return out; } /// In an *invocation-mode* state (registrations table populated by re- /// running the script), push the handler function for `tool_name` onto the /// stack. Caller is responsible for popping it after use. /// /// Returns LuaHandlerNotFound if no registration with that name exists. pub fn pushHandler(L: *c.lua_State, tool_name: []const u8) BridgeError!void { _ = c.lua_rawgetp(L, LUA_REGISTRYINDEX, ®istrations_key); const n: usize = @intCast(c.lua_rawlen(L, -1)); var i: usize = 1; while (i <= n) : (i += 1) { _ = c.lua_rawgeti(L, -1, @intCast(i)); _ = c.lua_getfield(L, -1, "name"); var len: usize = 0; const ptr = c.lua_tolstring(L, -1, &len); const matched = ptr != null and std.mem.eql(u8, ptr[0..len], tool_name); c.lua_settop(L, c.lua_gettop(L) - 1); // pop name if (matched) { // Replace the record with its handler field. _ = c.lua_getfield(L, -1, "handler"); // Stack: ..., regs_table, record, handler. Remove record, regs_table. c.lua_copy(L, -1, -3); c.lua_settop(L, c.lua_gettop(L) - 2); return; } c.lua_settop(L, c.lua_gettop(L) - 1); // pop record } c.lua_settop(L, c.lua_gettop(L) - 1); // pop regs table return BridgeError.LuaHandlerNotFound; } /// Convert raw JSON bytes into a Lua value and push it onto the stack. /// Top-level value must be a JSON object (matches our schema convention /// that tool input is always an object). pub fn pushJsonAsLua( L: *c.lua_State, arena: Allocator, input: []const u8, ) BridgeError!void { var parsed = std.json.parseFromSlice(std.json.Value, arena, input, .{}) catch { return BridgeError.InputNotJsonObject; }; defer parsed.deinit(); if (parsed.value != .object) return BridgeError.InputNotJsonObject; pushJsonValue(L, parsed.value) catch return BridgeError.OutOfMemory; } /// Read a tool handler's return value at `idx` and convert it into an /// owned `panto.ResultParts`. Two accepted shapes: /// /// * a plain string -> one `.text` part; /// * a table `{ text = "...", attachments = { { media_type = "...", /// data = "..." }, ... } }` -> an optional `.text` part followed by /// one `.media` part per attachment (`data` is base64-encoded bytes). /// /// Every returned slice/part owns its bytes (allocated with `allocator`); /// the caller frees via `panto.ResultParts.deinit`. pub fn readHandlerResult( L: *c.lua_State, idx: c_int, allocator: Allocator, ) BridgeError!panto.ResultParts { const ty = c.lua_type(L, idx); if (ty == T_STRING) { var len: usize = 0; const ptr = c.lua_tolstring(L, idx, &len); if (ptr == null) return BridgeError.BadHandlerReturn; return panto.ResultParts.fromText(allocator, ptr[0..len]) catch return BridgeError.OutOfMemory; } if (ty != T_TABLE) return BridgeError.BadHandlerReturn; return readHandlerResultTable(L, idx, allocator); } /// Read a Lua array-of-strings at stack index `idx` into a freshly /// allocated `[][]u8` (each line owned, copied with `alloc`). Used by the /// bridged component vtable to marshal a Lua component's `render` return /// value (`{ "line1", "line2", ... }`) into engine line slices. /// /// Non-string array entries are coerced via `lua_tolstring` (so numbers /// render as their text); a nil/absent entry ends the array (Lua's `#` /// border). Returns an empty slice for an empty/zero-length table. The /// caller owns the result and every inner slice. pub fn readLinesArray( L: *c.lua_State, idx: c_int, alloc: Allocator, ) BridgeError![][]u8 { if (c.lua_type(L, idx) != T_TABLE) return BridgeError.BadHandlerReturn; const abs = c.lua_absindex(L, idx); const n: usize = @intCast(c.lua_rawlen(L, abs)); if (n == 0) return alloc.alloc([]u8, 0) catch BridgeError.OutOfMemory; var out = alloc.alloc([]u8, n) catch return BridgeError.OutOfMemory; var made: usize = 0; errdefer { for (out[0..made]) |s| alloc.free(s); alloc.free(out); } var i: usize = 1; while (i <= n) : (i += 1) { _ = c.lua_rawgeti(L, abs, @intCast(i)); defer c.lua_settop(L, c.lua_gettop(L) - 1); var len: usize = 0; // lua_tolstring coerces numbers to strings in place; strings pass // through. A non-coercible value (table/function/nil) yields null. const ptr = c.lua_tolstring(L, -1, &len); if (ptr == null) return BridgeError.BadHandlerReturn; out[i - 1] = alloc.dupe(u8, ptr[0..len]) catch return BridgeError.OutOfMemory; made = i; } return out; } /// Read a string field `name` from the table at `tbl_idx`. Returns null /// if absent/nil, an error if present-but-not-a-string. The returned /// slice borrows from Lua's internal buffer — copy before popping. fn optStringField(L: *c.lua_State, tbl_idx: c_int, name: [*:0]const u8) BridgeError!?[]const u8 { const t = c.lua_getfield(L, tbl_idx, name); if (t == T_NIL) { c.lua_settop(L, c.lua_gettop(L) - 1); return null; } if (t != T_STRING) { c.lua_settop(L, c.lua_gettop(L) - 1); return BridgeError.BadHandlerReturn; } var len: usize = 0; const ptr = c.lua_tolstring(L, -1, &len); if (ptr == null) { c.lua_settop(L, c.lua_gettop(L) - 1); return BridgeError.BadHandlerReturn; } // Note: value still on stack; caller pops after copying. return ptr[0..len]; } fn readHandlerResultTable( L: *c.lua_State, idx: c_int, allocator: Allocator, ) BridgeError!panto.ResultParts { var parts: std.ArrayList(panto.ResultPart) = .empty; errdefer { for (parts.items) |p| p.deinit(allocator); parts.deinit(allocator); } // Optional `text` field. { const maybe = try optStringField(L, idx, "text"); if (maybe) |s| { const owned = allocator.dupe(u8, s) catch return BridgeError.OutOfMemory; c.lua_settop(L, c.lua_gettop(L) - 1); // pop field value parts.append(allocator, .{ .text = owned }) catch { allocator.free(owned); return BridgeError.OutOfMemory; }; } } // Optional `attachments` array. { const t = c.lua_getfield(L, idx, "attachments"); defer c.lua_settop(L, c.lua_gettop(L) - 1); // pop attachments if (t == T_TABLE) { const n: usize = @intCast(c.lua_rawlen(L, -1)); var i: usize = 1; while (i <= n) : (i += 1) { _ = c.lua_rawgeti(L, -1, @intCast(i)); // push attachment table defer c.lua_settop(L, c.lua_gettop(L) - 1); // pop attachment if (c.lua_type(L, -1) != T_TABLE) return BridgeError.BadHandlerReturn; // `media_type` is an optional hint; libpanto detects the // type from the raw bytes when it's absent. const media_type: ?[]const u8 = if (try optStringField(L, -1, "media_type")) |mt_slice| blk: { const owned = allocator.dupe(u8, mt_slice) catch return BridgeError.OutOfMemory; c.lua_settop(L, c.lua_gettop(L) - 1); // pop media_type value break :blk owned; } else null; errdefer if (media_type) |mt| allocator.free(mt); const data_slice = (try optStringField(L, -1, "data")) orelse return BridgeError.BadHandlerReturn; const data = allocator.dupe(u8, data_slice) catch return BridgeError.OutOfMemory; c.lua_settop(L, c.lua_gettop(L) - 1); // pop data value errdefer allocator.free(data); parts.append(allocator, .{ .media = .{ .media_type = media_type, .data = data, } }) catch return BridgeError.OutOfMemory; } } else if (t != T_NIL) { return BridgeError.BadHandlerReturn; } } const items = parts.toOwnedSlice(allocator) catch return BridgeError.OutOfMemory; return .{ .items = items }; } /// Test-only: fabricate a minimal `panto` module table `{ ext = }` /// and wire `require('panto')` to it, mirroring what the runtime's /// `installPantoModule` does in production (minus the native agent/stream /// surface, which needs a staged `panto.so` unavailable in unit tests). /// /// Production code never calls this — it `require`s the real native /// module. Unit tests that load extension scripts (which `require('panto')` /// for `panto.ext.*`) call `install(L)` then this, so those scripts /// resolve without a `.so` on disk. pub fn installTestPantoTable(L: *c.lua_State) void { c.lua_createtable(L, 0, 1); // panto pushExtTable(L); c.lua_setfield(L, -2, "ext"); // panto.ext = ext installPantoPreload(L); // preload.panto -> panto_table_key setPantoTable(L); // stash; leaves panto on stack c.lua_settop(L, c.lua_gettop(L) - 1); // pop panto } // --------------------------------------------------------------------------- // Lua-callable C functions // --------------------------------------------------------------------------- /// Implementation of `panto.ext.register_tool { name=, description=, schema=, handler= }`. /// /// Expects a single table argument with the four named fields. Validates /// each field type, serializes `schema` to JSON, and appends a record to /// the registrations table at `registry[®istrations_key]`. Throws a Lua /// error via `luaL_error` if anything is malformed — that propagates out /// of the running script as a Lua exception, which our `loadFile` surfaces /// as `LuaRunFailed`. fn registerToolThunk(L_opt: ?*c.lua_State) callconv(.c) c_int { const L = L_opt.?; c.luaL_checktype(L, 1, T_TABLE); // Pull each named field onto the stack and type-check it. After this // block, the stack layout is: // 1: args table (input) // 2: name (string) // 3: description (string) // 4: schema (table) // 5: handler (function) expectField(L, 1, "name", T_STRING); expectField(L, 1, "description", T_STRING); expectField(L, 1, "schema", T_TABLE); expectField(L, 1, "handler", T_FUNCTION); // Serialize the schema table to JSON, leaving the string on top. pushValueAsJson(L, 4) catch |err| { const msg = switch (err) { BridgeError.OutOfMemory => "register_tool: out of memory serializing schema", else => "register_tool: schema is not JSON-serializable", }; _ = c.luaL_error(L, msg); unreachable; }; // Stack now: args(1), name(2), desc(3), schema(4), handler(5), schema_json(6) // Build the record table. c.lua_createtable(L, 0, 4); c.lua_pushvalue(L, 2); c.lua_setfield(L, -2, "name"); c.lua_pushvalue(L, 3); c.lua_setfield(L, -2, "description"); c.lua_pushvalue(L, 6); // schema_json c.lua_setfield(L, -2, "schema_json"); c.lua_pushvalue(L, 5); // handler c.lua_setfield(L, -2, "handler"); // Append the record to the registrations table. _ = c.lua_rawgetp(L, LUA_REGISTRYINDEX, ®istrations_key); // Stack: ..., record, regs_table const n: c_int = @intCast(c.lua_rawlen(L, -1)); c.lua_pushvalue(L, -2); // copy record above regs_table c.lua_rawseti(L, -2, n + 1); c.lua_settop(L, c.lua_gettop(L) - 1); // pop regs_table return 0; } /// Implementation of `panto.ext.register_command { name=, description=, handler= }`. /// /// Expects a single table argument with three named fields. The handler /// is a function `function(args) ... end` where `args` is the trimmed /// remainder of the slash-command line (a string, possibly empty). Its /// return value is ignored — commands act by side effect, like the /// builtin Zig commands. Appends a `{ name, description, handler }` /// record to the command registrations table. fn registerCommandThunk(L_opt: ?*c.lua_State) callconv(.c) c_int { const L = L_opt.?; c.luaL_checktype(L, 1, T_TABLE); // Stack after these pulls: // 1: args table (input) // 2: name (string) // 3: description (string) // 4: handler (function) expectFieldNamed(L, 1, "name", T_STRING, "register_command"); expectFieldNamed(L, 1, "description", T_STRING, "register_command"); expectFieldNamed(L, 1, "handler", T_FUNCTION, "register_command"); // Build the record table { name, description, handler }. c.lua_createtable(L, 0, 3); c.lua_pushvalue(L, 2); c.lua_setfield(L, -2, "name"); c.lua_pushvalue(L, 3); c.lua_setfield(L, -2, "description"); c.lua_pushvalue(L, 4); // handler c.lua_setfield(L, -2, "handler"); // Append to the command registrations table. _ = c.lua_rawgetp(L, LUA_REGISTRYINDEX, &command_registrations_key); // Stack: ..., record, regs_table const n: c_int = @intCast(c.lua_rawlen(L, -1)); c.lua_pushvalue(L, -2); // copy record above regs_table c.lua_rawseti(L, -2, n + 1); c.lua_settop(L, c.lua_gettop(L) - 1); // pop regs_table return 0; } /// Implementation of `panto.ext.on(event_name, handler)`. /// /// Expects a string event name and a function handler (the two-positional- /// argument form, NOT a named-args table — `on` is the subscribe verb). /// Appends a `{ event, handler }` record to the on-registrations table, in /// call order, so the runtime can register them into the native `EventBus` /// in registration order (§7.3). fn registerOnThunk(L_opt: ?*c.lua_State) callconv(.c) c_int { const L = L_opt.?; c.luaL_checktype(L, 1, T_STRING); c.luaL_checktype(L, 2, T_FUNCTION); // Build the record { event = , handler = }. c.lua_createtable(L, 0, 2); c.lua_pushvalue(L, 1); c.lua_setfield(L, -2, "event"); c.lua_pushvalue(L, 2); c.lua_setfield(L, -2, "handler"); // Append to the on-registrations table. _ = c.lua_rawgetp(L, LUA_REGISTRYINDEX, &on_registrations_key); const n: c_int = @intCast(c.lua_rawlen(L, -1)); c.lua_pushvalue(L, -2); // copy record above regs_table c.lua_rawseti(L, -2, n + 1); c.lua_settop(L, c.lua_gettop(L) - 1); // pop regs_table return 0; } /// Push `args_table[field_name]` onto the stack and assert it has the /// expected type. Raises a Lua error if missing or wrong type. fn expectField( L: *c.lua_State, args_idx: c_int, comptime field_name: [:0]const u8, expected_type: c_int, ) void { expectFieldNamed(L, args_idx, field_name, expected_type, "register_tool"); } /// Like `expectField`, but lets the caller name the bridge function in /// the error message (`register_tool` vs `register_command`). fn expectFieldNamed( L: *c.lua_State, args_idx: c_int, comptime field_name: [:0]const u8, expected_type: c_int, comptime fn_name: [:0]const u8, ) void { _ = c.lua_getfield(L, args_idx, field_name.ptr); const got = c.lua_type(L, -1); if (got != expected_type) { const fmt: [:0]const u8 = fn_name ++ ": field '%s' must be %s (got %s)"; _ = c.luaL_error( L, fmt.ptr, field_name.ptr, c.lua_typename(L, expected_type), c.lua_typename(L, got), ); unreachable; } } // --------------------------------------------------------------------------- // JSON <-> Lua conversion // --------------------------------------------------------------------------- /// Serialize the Lua value at stack index `idx` to a JSON string and push /// that string onto the stack. Accepts strings, numbers, booleans, nil, /// and (array- or object-shaped) tables. Used for tool schemas at /// registration and for `tool_call_complete.input` event overrides. pub fn pushValueAsJson(L: *c.lua_State, idx: c_int) BridgeError!void { var aw: std.Io.Writer.Allocating = .init(std.heap.c_allocator); defer aw.deinit(); var s: std.json.Stringify = .{ .writer = &aw.writer }; writeLuaValueAsJson(L, idx, &s) catch return BridgeError.OutOfMemory; const bytes = aw.written(); _ = c.lua_pushlstring(L, bytes.ptr, bytes.len); } /// Serialize the value at stack index `idx` to JSON via `stringifier`. We /// allow strings, numbers, booleans, nil (→ JSON null), arrays (Lua tables /// with sequential integer keys 1..n), and objects (any other table). fn writeLuaValueAsJson(L: *c.lua_State, idx: c_int, w: *std.json.Stringify) anyerror!void { switch (c.lua_type(L, idx)) { T_NIL => try w.write(null), T_BOOLEAN => try w.write(c.lua_toboolean(L, idx) != 0), T_NUMBER => { // Distinguish integer vs float so we emit clean integers. var isnum: c_int = 0; const as_int = c.lua_tointegerx(L, idx, &isnum); if (isnum != 0) { try w.write(as_int); } else { const as_float = c.lua_tonumberx(L, idx, null); try w.write(as_float); } }, T_STRING => { var len: usize = 0; const ptr = c.lua_tolstring(L, idx, &len); try w.write(ptr[0..len]); }, T_TABLE => try writeLuaTableAsJson(L, idx, w), else => return error.UnsupportedLuaType, } } /// Decide whether the table at `idx` is JSON-array-shaped or object-shaped /// and serialize accordingly. /// /// Heuristic: if `lua_rawlen > 0`, treat it as an array (Lua's standard /// length operator returns the array-part border, so this catches the /// common case of `{ "a", "b", "c" }`). Otherwise iterate via `lua_next` /// and emit a JSON object keyed by stringified keys. /// /// An empty Lua table is ambiguous (could be either) and we serialize it /// as `{}`, since JSON Schema usage almost always wants empty-object /// shape (e.g. `properties = {}`). fn writeLuaTableAsJson(L: *c.lua_State, idx_in: c_int, w: *std.json.Stringify) !void { // Normalize negative indices since we'll be pushing more on the stack. const abs_idx = c.lua_absindex(L, idx_in); const len = c.lua_rawlen(L, abs_idx); if (len > 0) { try w.beginArray(); var i: c.lua_Integer = 1; while (i <= @as(c.lua_Integer, @intCast(len))) : (i += 1) { _ = c.lua_rawgeti(L, abs_idx, i); try writeLuaValueAsJson(L, -1, w); c.lua_settop(L, c.lua_gettop(L) - 1); } try w.endArray(); return; } try w.beginObject(); c.lua_pushnil(L); // first key while (c.lua_next(L, abs_idx) != 0) { // Stack: ..., key, value. Key must be a string for JSON objects. if (c.lua_type(L, -2) != T_STRING) { // Pop value, leave key for next lua_next iteration. c.lua_settop(L, c.lua_gettop(L) - 1); return error.UnsupportedLuaKey; } var klen: usize = 0; // CAREFUL: lua_tolstring on a non-string-key would mutate the key // and break lua_next. We already verified it's T_STRING above. const kptr = c.lua_tolstring(L, -2, &klen); try w.objectField(kptr[0..klen]); try writeLuaValueAsJson(L, -1, w); c.lua_settop(L, c.lua_gettop(L) - 1); // pop value, keep key } try w.endObject(); } /// Push a parsed `std.json.Value` onto the stack. fn pushJsonValue(L: *c.lua_State, v: std.json.Value) !void { switch (v) { .null => c.lua_pushnil(L), .bool => |b| c.lua_pushboolean(L, if (b) 1 else 0), .integer => |i| c.lua_pushinteger(L, @intCast(i)), .float => |f| c.lua_pushnumber(L, f), .number_string => |s| { // Best effort: try integer, else float. if (std.fmt.parseInt(c.lua_Integer, s, 10)) |i| { c.lua_pushinteger(L, i); } else |_| { const f = try std.fmt.parseFloat(c.lua_Number, s); c.lua_pushnumber(L, f); } }, .string => |s| _ = c.lua_pushlstring(L, s.ptr, s.len), .array => |arr| { c.lua_createtable(L, @intCast(arr.items.len), 0); for (arr.items, 0..) |item, i| { try pushJsonValue(L, item); c.lua_rawseti(L, -2, @intCast(i + 1)); } }, .object => |obj| { c.lua_createtable(L, 0, @intCast(obj.count())); var it = obj.iterator(); while (it.next()) |kv| { // Push key as Lua string (length-prefixed; doesn't need NUL). const key = kv.key_ptr.*; _ = c.lua_pushlstring(L, key.ptr, key.len); try pushJsonValue(L, kv.value_ptr.*); // Stack: ..., table, key, value -> table[key]=value, leaving table. c.lua_rawset(L, -3); } }, } } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- /// Read a string-typed field `field_name` from the record at stack index /// `record_idx`, duplicate its bytes into `arena`, and return the slice. fn readStringField( L: *c.lua_State, record_idx: c_int, field_name: [:0]const u8, arena: Allocator, ) BridgeError![]const u8 { _ = c.lua_getfield(L, record_idx, field_name.ptr); defer c.lua_settop(L, c.lua_gettop(L) - 1); if (c.lua_type(L, -1) != T_STRING) return BridgeError.BadRegistration; var len: usize = 0; const ptr = c.lua_tolstring(L, -1, &len); if (ptr == null) return BridgeError.BadRegistration; return arena.dupe(u8, ptr[0..len]) catch BridgeError.OutOfMemory; } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- test "install creates the ext table with register_tool/register_command/on/emit" { const L = c.luaL_newstate() orelse return error.LuaInitFailed; defer c.lua_close(L); c.luaL_openlibs(L); install(L); // No `panto` global, and `install` alone does NOT build the module // table — the native module supplies it via `require('panto')`, and // the host attaches `ext` in `installPantoModule`. So before that // wiring the module slot is empty. try std.testing.expectEqual(@as(c_int, T_NIL), c.lua_getglobal(L, "panto")); c.lua_settop(L, c.lua_gettop(L) - 1); pushPantoTable(L); try std.testing.expectEqual(@as(c_int, T_NIL), c.lua_type(L, -1)); c.lua_settop(L, c.lua_gettop(L) - 1); // The `ext` table exists and carries the four authoring functions. pushExtTable(L); try std.testing.expectEqual(@as(c_int, T_TABLE), c.lua_type(L, -1)); inline for (.{ "register_tool", "register_command", "on", "emit" }) |field| { _ = c.lua_getfield(L, -1, field); try std.testing.expectEqual(@as(c_int, T_FUNCTION), c.lua_type(L, -1)); c.lua_settop(L, c.lua_gettop(L) - 1); } // After the test wiring, `require('panto')` resolves to a `{ ext }` // module table. installTestPantoTable(L); pushPantoTable(L); try std.testing.expectEqual(@as(c_int, T_TABLE), c.lua_type(L, -1)); _ = c.lua_getfield(L, -1, "ext"); try std.testing.expectEqual(@as(c_int, T_TABLE), c.lua_type(L, -1)); } test "panto.ext.on records event registrations in order" { const L = c.luaL_newstate() orelse return error.LuaInitFailed; defer c.lua_close(L); c.luaL_openlibs(L); install(L); installTestPantoTable(L); const script = \\local panto = require("panto") \\panto.ext.on("tool", function(e) end) \\panto.ext.on("assistant_text", function(e) end) \\panto.ext.on("tool", function(e) end) ; if (c.luaL_loadstring(L, script) != 0 or c.lua_pcallk(L, 0, 0, 0, 0, null) != 0) { var len: usize = 0; const msg = c.lua_tolstring(L, -1, &len); std.debug.print("lua error: {s}\n", .{msg[0..len]}); return error.LuaScriptFailed; } var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); const ons = try harvestOnRegistrations(L, arena_state.allocator()); try std.testing.expectEqual(@as(usize, 3), ons.len); try std.testing.expectEqualStrings("tool", ons[0].event); try std.testing.expectEqualStrings("assistant_text", ons[1].event); try std.testing.expectEqualStrings("tool", ons[2].event); } test "readLinesArray marshals a Lua array of strings" { const L = c.luaL_newstate() orelse return error.LuaInitFailed; defer c.lua_close(L); c.luaL_openlibs(L); if (c.luaL_loadstring(L, "return { \"a\", \"bb\", \"ccc\" }") != 0 or c.lua_pcallk(L, 0, 1, 0, 0, null) != 0) { return error.LuaScriptFailed; } const lines = try readLinesArray(L, -1, std.testing.allocator); defer { for (lines) |ln| std.testing.allocator.free(ln); std.testing.allocator.free(lines); } try std.testing.expectEqual(@as(usize, 3), lines.len); try std.testing.expectEqualStrings("a", lines[0]); try std.testing.expectEqualStrings("bb", lines[1]); try std.testing.expectEqualStrings("ccc", lines[2]); } test "register_command records name and description" { const L = c.luaL_newstate() orelse return error.LuaInitFailed; defer c.lua_close(L); c.luaL_openlibs(L); install(L); installTestPantoTable(L); const script = \\local panto = require("panto") \\panto.ext.register_command { \\ name = "hello", \\ description = "Greets the user.", \\ handler = function(args) return "hi " .. args end, \\} ; if (c.luaL_loadstring(L, script) != 0 or c.lua_pcallk(L, 0, 0, 0, 0, null) != 0) { var len: usize = 0; const msg = c.lua_tolstring(L, -1, &len); std.debug.print("lua error: {s}\n", .{msg[0..len]}); return error.LuaScriptFailed; } var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); const cmds = try harvestCommandRegistrations(L, arena_state.allocator()); try std.testing.expectEqual(@as(usize, 1), cmds.len); try std.testing.expectEqualStrings("hello", cmds[0].name); try std.testing.expectEqualStrings("Greets the user.", cmds[0].description); } test "register_command rejects a non-function handler" { const L = c.luaL_newstate() orelse return error.LuaInitFailed; defer c.lua_close(L); c.luaL_openlibs(L); install(L); installTestPantoTable(L); const script = \\local panto = require("panto") \\panto.ext.register_command { \\ name = "bad", description = "d", handler = 42, \\} ; const loaded = c.luaL_loadstring(L, script) == 0; try std.testing.expect(loaded); // Should raise a Lua error (non-zero pcall result). try std.testing.expect(c.lua_pcallk(L, 0, 0, 0, 0, null) != 0); var len: usize = 0; const msg = c.lua_tolstring(L, -1, &len); try std.testing.expect(std.mem.indexOf(u8, msg[0..len], "register_command") != null); } test "register_tool records name, description, schema_json" { const L = c.luaL_newstate() orelse return error.LuaInitFailed; defer c.lua_close(L); c.luaL_openlibs(L); install(L); installTestPantoTable(L); const script = \\local panto = require("panto") \\panto.ext.register_tool { \\ name = "echo", \\ description = "Echoes its input back.", \\ schema = { type = "object", properties = { msg = { type = "string" } } }, \\ handler = function(input) return input.msg end, \\} ; if (c.luaL_loadstring(L, script) != 0 or c.lua_pcallk(L, 0, 0, 0, 0, null) != 0) { var len: usize = 0; const msg = c.lua_tolstring(L, -1, &len); std.debug.print("lua error: {s}\n", .{msg[0..len]}); return error.LuaScriptFailed; } var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); const regs = try harvestRegistrations(L, arena_state.allocator()); try std.testing.expectEqual(@as(usize, 1), regs.len); try std.testing.expectEqualStrings("echo", regs[0].name); try std.testing.expectEqualStrings("Echoes its input back.", regs[0].description); // schema_json should be valid JSON containing "type": "object" try std.testing.expect(std.mem.indexOf(u8, regs[0].schema_json, "\"type\"") != null); try std.testing.expect(std.mem.indexOf(u8, regs[0].schema_json, "\"object\"") != null); } test "handler invocation: input parsed, result captured" { const L = c.luaL_newstate() orelse return error.LuaInitFailed; defer c.lua_close(L); c.luaL_openlibs(L); install(L); installTestPantoTable(L); const script = \\local panto = require("panto") \\panto.ext.register_tool { \\ name = "echo", description = "echoes", \\ schema = { type = "object" }, \\ handler = function(input) return "got: " .. input.msg end, \\} ; if (c.luaL_loadstring(L, script) != 0 or c.lua_pcallk(L, 0, 0, 0, 0, null) != 0) { return error.LuaScriptFailed; } try pushHandler(L, "echo"); try std.testing.expectEqual(@as(c_int, T_FUNCTION), c.lua_type(L, -1)); var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); try pushJsonAsLua(L, arena_state.allocator(), "{\"msg\":\"hello\"}"); // Call: 1 arg, 1 return. if (c.lua_pcallk(L, 1, 1, 0, 0, null) != 0) { return error.LuaCallFailed; } const result = try readHandlerResult(L, -1, std.testing.allocator); defer result.deinit(std.testing.allocator); try std.testing.expectEqual(@as(usize, 1), result.items.len); try std.testing.expectEqualStrings("got: hello", result.items[0].text); } test "readHandlerResult: table with text and attachments" { const L = c.luaL_newstate() orelse return error.LuaInitFailed; defer c.lua_close(L); c.luaL_openlibs(L); install(L); installTestPantoTable(L); const script = \\return { \\ text = "see image", \\ attachments = { \\ { media_type = "image/png", data = "AAA=" }, \\ { media_type = "application/pdf", data = "BBB=" }, \\ }, \\} ; if (c.luaL_loadstring(L, script) != 0 or c.lua_pcallk(L, 0, 1, 0, 0, null) != 0) { return error.LuaScriptFailed; } const result = try readHandlerResult(L, -1, std.testing.allocator); defer result.deinit(std.testing.allocator); try std.testing.expectEqual(@as(usize, 3), result.items.len); try std.testing.expectEqualStrings("see image", result.items[0].text); try std.testing.expectEqualStrings("image/png", result.items[1].media.media_type.?); try std.testing.expectEqualStrings("AAA=", result.items[1].media.data); try std.testing.expectEqualStrings("application/pdf", result.items[2].media.media_type.?); try std.testing.expectEqualStrings("BBB=", result.items[2].media.data); } test "handler crash: error message surfaces via xpcall traceback hook" { const L = c.luaL_newstate() orelse return error.LuaInitFailed; defer c.lua_close(L); c.luaL_openlibs(L); install(L); installTestPantoTable(L); const script = \\local panto = require("panto") \\panto.ext.register_tool { \\ name = "boom", description = "crashes", \\ schema = { type = "object" }, \\ handler = function(input) error("explosion") end, \\} ; if (c.luaL_loadstring(L, script) != 0 or c.lua_pcallk(L, 0, 0, 0, 0, null) != 0) { return error.LuaScriptFailed; } // Push a traceback error handler at the bottom of the call frame. _ = c.lua_getglobal(L, "debug"); _ = c.lua_getfield(L, -1, "traceback"); c.lua_copy(L, -1, -2); c.lua_settop(L, c.lua_gettop(L) - 1); const errfunc_idx = c.lua_gettop(L); try pushHandler(L, "boom"); c.lua_pushnil(L); // input arg // pcallk with errfunc index = where we put debug.traceback. const rc = c.lua_pcallk(L, 1, 1, errfunc_idx, 0, null); try std.testing.expect(rc != 0); var len: usize = 0; const msg = c.lua_tolstring(L, -1, &len); try std.testing.expect(msg != null); const slice = msg[0..len]; try std.testing.expect(std.mem.indexOf(u8, slice, "explosion") != null); // Should also contain a traceback marker since we used debug.traceback. try std.testing.expect(std.mem.indexOf(u8, slice, "stack traceback") != null); }