diff options
| author | T <t@tjp.lol> | 2026-05-26 11:10:15 -0600 |
|---|---|---|
| committer | T <t@tjp.lol> | 2026-05-26 11:14:57 -0600 |
| commit | e65ea596306721d3a2327cf6230e7106db77a414 (patch) | |
| tree | 18f7848fc737dc743993b6ca1ff2e5a954327307 /src/lua_tool.zig | |
| parent | edad4fc123099a780d960feea4fe37efb6d979d8 (diff) | |
basic lua tools
Diffstat (limited to 'src/lua_tool.zig')
| -rw-r--r-- | src/lua_tool.zig | 341 |
1 files changed, 341 insertions, 0 deletions
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])); + } + } +} |
