From e65ea596306721d3a2327cf6230e7106db77a414 Mon Sep 17 00:00:00 2001 From: T Date: Tue, 26 May 2026 11:10:15 -0600 Subject: basic lua tools --- examples/extensions/echo.lua | 22 ++ src/lua_bridge.zig | 571 +++++++++++++++++++++++++++++++++++++++++++ src/lua_tool.zig | 341 ++++++++++++++++++++++++++ src/main.zig | 39 ++- 4 files changed, 966 insertions(+), 7 deletions(-) create mode 100644 examples/extensions/echo.lua create mode 100644 src/lua_bridge.zig create mode 100644 src/lua_tool.zig diff --git a/examples/extensions/echo.lua b/examples/extensions/echo.lua new file mode 100644 index 0000000..d13920e --- /dev/null +++ b/examples/extensions/echo.lua @@ -0,0 +1,22 @@ +-- A trivial Lua tool that echoes back whatever the LLM asks it to. +-- Useful for exercising the panto CLI's Lua extension path end-to-end. +-- +-- Load with: panto --lua examples/extensions/echo.lua + +panto.register_tool { + name = "echo", + description = "Echo back the given message. Useful for testing whether tools work.", + schema = { + type = "object", + properties = { + message = { + type = "string", + description = "The text to echo back.", + }, + }, + required = { "message" }, + }, + handler = function(input) + return "echo: " .. input.message + end, +} diff --git a/src/lua_bridge.zig b/src/lua_bridge.zig new file mode 100644 index 0000000..3d4a24f --- /dev/null +++ b/src/lua_bridge.zig @@ -0,0 +1,571 @@ +//! Lua C-API bridge for the panto CLI. +//! +//! Exposes a `panto` global table inside any `lua_State` we construct, with +//! a single function: +//! +//! panto.register_tool { +//! name = "...", +//! description = "...", +//! schema = { ... }, -- JSON Schema as a Lua table +//! handler = function(input) ... end, +//! } +//! +//! 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. The Zig side then reads that table to decide what to do with it: +//! +//! - **Discovery** (`harvestRegistrations`): runs an extension script once at +//! startup to learn the *names*, *descriptions*, and *schemas* of every +//! tool it declares. The handler functions are discarded — that throwaway +//! state will be closed immediately. +//! +//! - **Invocation** (`fetchHandler` + `runHandler`): per tool call, we open +//! a fresh `lua_State`, re-run the script, then look up the handler by +//! name in the same registry table. +//! +//! No `lua_State` pooling, no shared mutable state across calls. Every +//! `LuaTool.invoke` builds and tears down its own state. This is slow per- +//! call (~ms of Lua startup) but mechanically the simplest model: there is +//! nothing that can leak between invocations. + +const std = @import("std"); +const Allocator = std.mem.Allocator; + +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 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. +var registrations_key: u8 = 0; + +/// A single declared tool, as harvested from a script's top-level call to +/// `panto.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, +}; + +// --------------------------------------------------------------------------- +// Public bridge API +// --------------------------------------------------------------------------- + +/// Install the `panto.register_tool` global into the given state. +/// +/// Also creates the registry table that holds harvested registrations and +/// the per-name handler references. +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); + + // Build the `panto` global table with `register_tool`. + c.lua_createtable(L, 0, 1); + c.lua_pushcclosure(L, registerToolThunk, 0); + c.lua_setfield(L, -2, "register_tool"); + c.lua_setglobal(L, "panto"); +} + +/// Load and execute a Lua source file in the given state. The file's +/// top-level code typically calls `panto.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; +} + +/// 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 Lua value at `idx` and serialize it to a JSON-compatible owned +/// byte string. Used to convert handler return values into ToolResult +/// content. For now we only accept string returns; extending to richer +/// types is straightforward but unnecessary for slice 2. +pub fn readHandlerResult( + L: *c.lua_State, + idx: c_int, + allocator: Allocator, +) BridgeError![]u8 { + if (c.lua_type(L, idx) != T_STRING) return BridgeError.BadHandlerReturn; + var len: usize = 0; + const ptr = c.lua_tolstring(L, idx, &len); + if (ptr == null) return BridgeError.BadHandlerReturn; + return allocator.dupe(u8, ptr[0..len]) catch BridgeError.OutOfMemory; +} + +// --------------------------------------------------------------------------- +// Lua-callable C functions +// --------------------------------------------------------------------------- + +/// Implementation of `panto.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. + pushSchemaAsJson(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; +} + +/// 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 { + _ = c.lua_getfield(L, args_idx, field_name.ptr); + const got = c.lua_type(L, -1); + if (got != expected_type) { + _ = c.luaL_error( + L, + "register_tool: field '%s' must be %s (got %s)", + field_name.ptr, + c.lua_typename(L, expected_type), + c.lua_typename(L, got), + ); + unreachable; + } +} + +// --------------------------------------------------------------------------- +// JSON <-> Lua conversion +// --------------------------------------------------------------------------- + +/// Serialize the Lua table at stack index `idx` to a JSON string and push +/// that string onto the stack. +fn pushSchemaAsJson(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 panto.register_tool global" { + const L = c.luaL_newstate() orelse return error.LuaInitFailed; + defer c.lua_close(L); + c.luaL_openlibs(L); + install(L); + + _ = c.lua_getglobal(L, "panto"); + try std.testing.expectEqual(@as(c_int, T_TABLE), c.lua_type(L, -1)); + _ = c.lua_getfield(L, -1, "register_tool"); + try std.testing.expectEqual(@as(c_int, T_FUNCTION), c.lua_type(L, -1)); +} + +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); + + const script = + \\panto.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); + + const script = + \\panto.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 std.testing.allocator.free(result); + try std.testing.expectEqualStrings("got: hello", result); +} + +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); + + const script = + \\panto.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); +} diff --git a/src/lua_tool.zig b/src/lua_tool.zig new file mode 100644 index 0000000..3064d2b --- /dev/null +++ b/src/lua_tool.zig @@ -0,0 +1,341 @@ +//! `LuaTool` — adapts a Lua-defined tool to libpanto's `Tool` interface. +//! +//! Every call to `invoke` opens a fresh `lua_State`, re-runs the extension +//! script (which calls `panto.register_tool(...)` and registers its handler +//! into the registrations table), locates the handler by name, runs it +//! under `xpcall` with a `debug.traceback` error handler, and tears the +//! state down before returning. No state is reused across calls. +//! +//! This is the simplest possible model: each `invoke` is hermetic. The +//! consequence is a few milliseconds of Lua startup per call, which is +//! invisible next to LLM round-trip latency. A pool can be added later +//! behind the same `LuaTool` interface without changing libpanto. + +const std = @import("std"); +const panto = @import("panto"); +const lua_bridge = @import("lua_bridge.zig"); + +const c = lua_bridge.c; +const Allocator = std.mem.Allocator; + +/// A LuaTool owns all the strings the `Tool` interface borrows (name, +/// description, schema_json), plus the path to the script it dispatches +/// to. `vtable.deinit` frees everything including the LuaTool itself. +pub const LuaTool = struct { + allocator: Allocator, + // Owned, NUL-terminated for `luaL_loadfilex`. + script_path_z: [:0]u8, + name_owned: []u8, + description_owned: []u8, + schema_owned: []u8, + + /// Build a LuaTool from a harvested registration plus the script path + /// it came from. All strings are copied into freshly-allocated bytes + /// owned by this LuaTool — the source slices can be freed after this + /// returns. + pub fn create( + allocator: Allocator, + script_path: []const u8, + name: []const u8, + description: []const u8, + schema_json: []const u8, + ) !panto.Tool { + const self = try allocator.create(LuaTool); + errdefer allocator.destroy(self); + + self.* = .{ + .allocator = allocator, + .script_path_z = try allocator.dupeZ(u8, script_path), + .name_owned = try allocator.dupe(u8, name), + .description_owned = try allocator.dupe(u8, description), + .schema_owned = try allocator.dupe(u8, schema_json), + }; + + return panto.Tool{ + .name = self.name_owned, + .description = self.description_owned, + .schema_json = self.schema_owned, + .ctx = self, + .vtable = &vtable, + }; + } + + fn freeAll(self: *LuaTool) void { + const a = self.allocator; + a.free(self.script_path_z); + a.free(self.name_owned); + a.free(self.description_owned); + a.free(self.schema_owned); + a.destroy(self); + } +}; + +const vtable: panto.Tool.VTable = .{ + .invoke = invoke, + .deinit = deinitTool, +}; + +fn invoke( + ctx: *anyopaque, + input: []const u8, + allocator: Allocator, +) anyerror![]u8 { + const self: *LuaTool = @ptrCast(@alignCast(ctx)); + return runLuaHandler(self, input, allocator); +} + +fn deinitTool(ctx: *anyopaque, _: Allocator) void { + const self: *LuaTool = @ptrCast(@alignCast(ctx)); + self.freeAll(); +} + +/// Open a fresh Lua state, re-load the script (running `panto.register_tool`), +/// push the handler for `self.name_owned`, push the parsed input, run under +/// `xpcall`, and return the result bytes. +fn runLuaHandler( + self: *LuaTool, + input: []const u8, + out_allocator: Allocator, +) anyerror![]u8 { + const L = c.luaL_newstate() orelse return error.LuaInitFailed; + defer c.lua_close(L); + c.luaL_openlibs(L); + lua_bridge.install(L); + + // Run the script so register_tool fires. + lua_bridge.loadFile(L, self.script_path_z) catch |err| { + logTopAsError(L, "lua: failed to load extension"); + return err; + }; + + // Push a traceback handler at the bottom of the upcoming call frame. + _ = c.lua_getglobal(L, "debug"); + _ = c.lua_getfield(L, -1, "traceback"); + // Replace the `debug` slot with its `traceback` field. + c.lua_copy(L, -1, -2); + c.lua_settop(L, c.lua_gettop(L) - 1); + const errfunc_idx = c.lua_gettop(L); + + try lua_bridge.pushHandler(L, self.name_owned); + + // Parse the LLM's JSON input into a Lua table and push as argument. + var arena_state = std.heap.ArenaAllocator.init(out_allocator); + defer arena_state.deinit(); + try lua_bridge.pushJsonAsLua(L, arena_state.allocator(), input); + + // Call handler(input). 1 arg, 1 return value, traceback at errfunc_idx. + const rc = c.lua_pcallk(L, 1, 1, errfunc_idx, 0, null); + if (rc != 0) { + logTopAsError(L, "lua: handler crashed"); + return error.LuaHandlerCrashed; + } + + return lua_bridge.readHandlerResult(L, -1, out_allocator); +} + +/// Best-effort: read the top of the Lua stack as a string and log it under +/// the given prefix. Always leaves the stack as we found it. +/// +/// In test builds we log at `warn` instead of `err` so the test runner +/// doesn't treat expected-failure paths (e.g. a crash-protection test) as +/// overall test failures. End users still see warnings in normal runs. +fn logTopAsError(L: *c.lua_State, prefix: []const u8) void { + var len: usize = 0; + const msg = c.lua_tolstring(L, -1, &len); + const is_test = @import("builtin").is_test; + if (msg != null) { + if (is_test) { + std.log.warn("{s}: {s}", .{ prefix, msg[0..len] }); + } else { + std.log.err("{s}: {s}", .{ prefix, msg[0..len] }); + } + } else { + if (is_test) { + std.log.warn("{s} (no error message)", .{prefix}); + } else { + std.log.err("{s} (no error message)", .{prefix}); + } + } +} + +// --------------------------------------------------------------------------- +// Standalone discovery helper +// --------------------------------------------------------------------------- + +/// Open a *throwaway* Lua state, run `script_path`, harvest every +/// `panto.register_tool` call into a slice of `LuaTool`s registered with +/// the given registry. The state is closed before returning; only the +/// metadata (name, description, schema) survives. Each tool rebuilds its +/// own state on every invocation. +/// +/// Returns the number of tools registered. On any failure, the caller +/// should treat the extension as not loaded — partial-success cleanup is +/// the caller's responsibility (typically: surface the error and abort). +pub fn loadExtension( + allocator: Allocator, + registry: *panto.ToolRegistry, + script_path: []const u8, +) !usize { + const path_z = try allocator.dupeZ(u8, script_path); + defer allocator.free(path_z); + + const L = c.luaL_newstate() orelse return error.LuaInitFailed; + defer c.lua_close(L); + c.luaL_openlibs(L); + lua_bridge.install(L); + + lua_bridge.loadFile(L, path_z) catch |err| { + logTopAsError(L, "lua: failed to load extension"); + return err; + }; + + var arena_state = std.heap.ArenaAllocator.init(allocator); + defer arena_state.deinit(); + + const regs = try lua_bridge.harvestRegistrations(L, arena_state.allocator()); + for (regs) |r| { + const tool = try LuaTool.create( + allocator, + script_path, + r.name, + r.description, + r.schema_json, + ); + // If registration fails (e.g. duplicate name), free the tool we just + // built and propagate the error. + registry.register(tool) catch |err| { + tool.vtable.deinit(tool.ctx, allocator); + return err; + }; + } + return regs.len; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +const testing = std.testing; +const Io = std.Io; + +fn writeTempScript(dir: Io.Dir, name: []const u8, source: []const u8) ![]const u8 { + try dir.writeFile(testing.io, .{ .sub_path = name, .data = source }); + // Construct an absolute path so luaL_loadfilex finds it regardless of cwd. + var buf: [std.fs.max_path_bytes]u8 = undefined; + const n = try dir.realPathFile(testing.io, name, &buf); + return testing.allocator.dupe(u8, buf[0..n]); +} + +test "loadExtension registers tools and invoke runs the handler" { + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + + const source = + \\panto.register_tool { + \\ name = "greet", description = "Says hi.", + \\ schema = { type = "object", properties = { name = { type = "string" } } }, + \\ handler = function(input) return "hi, " .. input.name end, + \\} + ; + const path = try writeTempScript(tmp.dir, "greet.lua", source); + defer testing.allocator.free(path); + + var registry = panto.ToolRegistry.init(testing.allocator); + defer registry.deinit(); + + const n = try loadExtension(testing.allocator, ®istry, path); + try testing.expectEqual(@as(usize, 1), n); + + const tool = registry.lookup("greet") orelse return error.NotRegistered; + try testing.expectEqualStrings("greet", tool.name); + try testing.expectEqualStrings("Says hi.", tool.description); + try testing.expect(std.mem.indexOf(u8, tool.schema_json, "\"object\"") != null); + + // Invoke through the vtable. + const result = try tool.vtable.invoke(tool.ctx, "{\"name\":\"travis\"}", testing.allocator); + defer testing.allocator.free(result); + try testing.expectEqualStrings("hi, travis", result); +} + +test "invoke surfaces handler crashes as LuaHandlerCrashed" { + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + + const source = + \\panto.register_tool { + \\ name = "boom", description = "crashes", + \\ schema = { type = "object" }, + \\ handler = function(input) error("kaboom") end, + \\} + ; + const path = try writeTempScript(tmp.dir, "boom.lua", source); + defer testing.allocator.free(path); + + var registry = panto.ToolRegistry.init(testing.allocator); + defer registry.deinit(); + + _ = try loadExtension(testing.allocator, ®istry, path); + const tool = registry.lookup("boom") orelse return error.NotRegistered; + + const result = tool.vtable.invoke(tool.ctx, "{}", testing.allocator); + try testing.expectError(error.LuaHandlerCrashed, result); +} + +test "concurrent invoke: each call gets its own Lua state" { + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + + // The handler returns the pointer-as-hex of `_G`, which differs between + // distinct Lua states. If two threads share a state, two of the four + // calls will return the same string. + const source = + \\panto.register_tool { + \\ name = "whoami", description = "state id", + \\ schema = { type = "object" }, + \\ handler = function(input) return tostring(_G) end, + \\} + ; + const path = try writeTempScript(tmp.dir, "whoami.lua", source); + defer testing.allocator.free(path); + + var registry = panto.ToolRegistry.init(testing.allocator); + defer registry.deinit(); + _ = try loadExtension(testing.allocator, ®istry, path); + const tool = registry.lookup("whoami") orelse return error.NotRegistered; + + const Worker = struct { + tool: *const panto.Tool, + out: *[]u8, + err: *?anyerror, + + fn run(self: @This()) void { + const r = self.tool.vtable.invoke(self.tool.ctx, "{}", testing.allocator) catch |e| { + self.err.* = e; + return; + }; + self.out.* = r; + } + }; + + var results: [4][]u8 = .{ undefined, undefined, undefined, undefined }; + var errs: [4]?anyerror = .{ null, null, null, null }; + var threads: [4]std.Thread = undefined; + for (&threads, 0..) |*t, i| { + t.* = try std.Thread.spawn(.{}, Worker.run, .{Worker{ + .tool = tool, + .out = &results[i], + .err = &errs[i], + }}); + } + for (&threads) |t| t.join(); + defer for (results) |r| if (r.len != 0) testing.allocator.free(r); + + for (errs) |e| try testing.expectEqual(@as(?anyerror, null), e); + + // All four "_G" identifiers should be distinct, proving distinct states. + for (0..4) |i| { + for ((i + 1)..4) |j| { + try testing.expect(!std.mem.eql(u8, results[i], results[j])); + } + } +} diff --git a/src/main.zig b/src/main.zig index 6fbdb4b..6407611 100644 --- a/src/main.zig +++ b/src/main.zig @@ -1,14 +1,18 @@ const std = @import("std"); const panto = @import("panto"); const ping_tool = @import("ping_tool.zig"); +const lua_bridge = @import("lua_bridge.zig"); +const lua_tool = @import("lua_tool.zig"); -// Lua 5.4 C API. Linked statically via build.zig from the upstream lua.org -// source tarball pinned in build.zig.zon. -const lua = @cImport({ - @cInclude("lua.h"); - @cInclude("lauxlib.h"); - @cInclude("lualib.h"); -}); +// Shorthand alias for the Lua C API. The bridge module owns the actual +// `@cImport`; we re-use it here so the smoke check uses identical types. +const lua = lua_bridge.c; + +test { + std.testing.refAllDecls(@This()); + _ = lua_bridge; + _ = lua_tool; +} const Receiver = panto.provider.Receiver; const ReceiverVTable = panto.provider.ReceiverVTable; @@ -221,6 +225,27 @@ pub fn main(init: std.process.Init) !void { // the tool-call loop against a real LLM. try agent.registerTool(ping_tool.tool()); + // Load any Lua extensions specified via `--lua ` flags. This is a + // slice-2 manual hook — slice 3 will replace it with directory discovery. + const argv = try init.minimal.args.toSlice(init.arena.allocator()); + var i: usize = 1; + while (i < argv.len) : (i += 1) { + const a = argv[i]; + if (std.mem.eql(u8, a, "--lua")) { + i += 1; + if (i >= argv.len) { + std.log.err("--lua requires a path argument", .{}); + return error.MissingLuaPath; + } + const path = argv[i]; + const n = lua_tool.loadExtension(alloc, &agent.registry, path) catch |err| { + std.log.err("failed to load Lua extension {s}: {t}", .{ path, err }); + return err; + }; + std.log.debug("lua: loaded {d} tool(s) from {s}", .{ n, path }); + } + } + const banner_model: []const u8 = switch (config) { inline else => |c| c.model, }; -- cgit v1.3