summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorT <t@tjp.lol>2026-05-26 11:10:15 -0600
committerT <t@tjp.lol>2026-05-26 11:14:57 -0600
commite65ea596306721d3a2327cf6230e7106db77a414 (patch)
tree18f7848fc737dc743993b6ca1ff2e5a954327307 /src
parentedad4fc123099a780d960feea4fe37efb6d979d8 (diff)
basic lua tools
Diffstat (limited to 'src')
-rw-r--r--src/lua_bridge.zig571
-rw-r--r--src/lua_tool.zig341
-rw-r--r--src/main.zig39
3 files changed, 944 insertions, 7 deletions
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, &registrations_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, &registrations_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, &registrations_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[&registrations_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, &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;
+}
+
+/// 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, &registry, 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, &registry, 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, &registry, 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 <path>` 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,
};