//! Long-lived Lua runtime, registered with libpanto as a single //! `ToolSource`. //! //! This replaces the per-call `lua_State` model of phase 3 (LuaTool + //! LuaStatePool). The CLI maintains exactly one `lua_State` for its //! entire lifetime. Every extension is loaded into it once; extension //! top-level code runs exactly once at startup. Tool handlers are //! stored in the Lua registry and looked up by tool name on each call. //! //! libpanto delivers all tool calls targeting Lua-defined tools in one //! `invoke_batch` per turn, on a single thread (see //! `libpanto/src/tool_source.zig`). This runtime then runs each call as //! a Lua *coroutine*. When (later) we wire in libuv via `luv`, a yield //! inside a coroutine returns control to the runtime, which drives //! `uv.run()` until any coroutine is resumable. //! //! For now (step 2 of LUA_MAKEOVER.md): no batteries yet. Each call's //! coroutine runs to completion synchronously. A handler that yields //! to nothing currently leaves the call permanently suspended — we //! surface that as a `LuaHandlerYielded` error so it's at least visible. //! Step 4 (install `luv`) and step 5 (wire `coro-*`) make yields //! productive. //! //! Concurrency contract for source-backed tools: "coroutine-safe within //! this runtime". Concurrent host entry into the same `lua_State` is //! *not* safe; libpanto's grouped-dispatch guarantees this never happens. const std = @import("std"); const Allocator = std.mem.Allocator; const panto = @import("panto"); const lua_bridge = @import("lua_bridge.zig"); const c = lua_bridge.c; const Io = std.Io; pub const SOURCE_NAME = "panto-lua"; /// Errors produced by the runtime above and beyond bridge errors. pub const RuntimeError = error{ LuaInitFailed, LuaHandlerNotFound, LuaHandlerYielded, LuaHandlerCrashed, BadHandlerReturn, InputNotJsonObject, OutOfMemory, }; /// Owned state for the runtime. pub const LuaRuntime = struct { allocator: Allocator, L: *c.lua_State, /// Tool declarations for the `ToolSource`, owned by this runtime. decls: std.array_list.Managed(panto.ToolDecl), /// Backing byte buffers for every string referenced by `decls`. strings: std.array_list.Managed([]u8), /// Map from tool name (borrowed from `decls`) to its handler ref in /// the Lua registry (`luaL_ref` index). handlers: std.StringHashMap(c_int), /// Create a new runtime. The `lua_State` is opened, standard libs /// loaded, and the `panto.register_tool` bridge installed. pub fn create(allocator: Allocator) !*LuaRuntime { const self = try allocator.create(LuaRuntime); errdefer allocator.destroy(self); const L = c.luaL_newstate() orelse return RuntimeError.LuaInitFailed; errdefer c.lua_close(L); c.luaL_openlibs(L); lua_bridge.install(L); self.* = .{ .allocator = allocator, .L = L, .decls = std.array_list.Managed(panto.ToolDecl).init(allocator), .strings = std.array_list.Managed([]u8).init(allocator), .handlers = std.StringHashMap(c_int).init(allocator), }; return self; } /// Tear down the runtime: free every owned string, unref every /// handler, close the Lua state. pub fn deinit(self: *LuaRuntime) void { // Unref handlers so future GCs collect them. Not strictly // necessary since we close the state next, but it documents // intent. var hit = self.handlers.iterator(); while (hit.next()) |entry| { c.luaL_unref(self.L, lua_bridge.LUA_REGISTRYINDEX, entry.value_ptr.*); } self.handlers.deinit(); c.lua_close(self.L); self.decls.deinit(); for (self.strings.items) |s| self.allocator.free(s); self.strings.deinit(); self.allocator.destroy(self); } /// Load and execute one Lua extension script in this runtime. /// /// `package_root`, if provided, is prepended to `package.path` so /// `require` finds sibling modules. /// /// All `panto.register_tool` calls in the script run during this /// call. The runtime then harvests the registrations table, /// transfers handler functions into the Lua registry (one `luaL_ref` /// per tool), and records each tool's metadata in `self.decls`. pub fn loadExtension( self: *LuaRuntime, script_path: []const u8, package_root: ?[]const u8, ) !void { const path_z = try self.allocator.dupeZ(u8, script_path); defer self.allocator.free(path_z); // Reset the registrations table to empty so we only harvest the // calls made by *this* script (not accumulated from prior ones). // The bridge re-installs the registrations table when called; // we want to call only that subset. Instead of re-installing // everything (which would also reset the panto global, fine), // create a fresh registrations table directly via the bridge. lua_bridge.resetRegistrations(self.L); if (package_root) |root| { const root_z = try self.allocator.dupeZ(u8, root); defer self.allocator.free(root_z); try prependPackagePath(self.L, root_z); } lua_bridge.loadFile(self.L, path_z) catch |err| { logTopAsError(self.L, "lua: failed to load extension"); return err; }; // Harvest the registrations table into our state. try self.harvestAndStoreHandlers(); } /// Load a single-tool Lua script and register the table it returns /// as if `panto.register_tool` had been called on that table. /// /// The script's top-level chunk must return a table with the same /// shape that `panto.register_tool` accepts: /// `{ name, description, schema, handler }`. This is the ergonomic /// form supported under a `tools/` directory. pub fn loadTool( self: *LuaRuntime, script_path: []const u8, package_root: ?[]const u8, ) !void { const path_z = try self.allocator.dupeZ(u8, script_path); defer self.allocator.free(path_z); lua_bridge.resetRegistrations(self.L); if (package_root) |root| { const root_z = try self.allocator.dupeZ(u8, root); defer self.allocator.free(root_z); try prependPackagePath(self.L, root_z); } // Run the file expecting exactly one returned value (the tool // table). Use luaL_loadfilex + lua_pcallk directly so we can // ask for a return value (the bridge's loadFile discards them). // // Push `panto.register_tool` *first*, then load+run the chunk so // its return value naturally lands above it; calling pcall then // consumes both in the right order. const L = self.L; _ = c.lua_getglobal(L, "panto"); _ = c.lua_getfield(L, -1, "register_tool"); c.lua_copy(L, -1, -2); // overwrite `panto` with `register_tool` c.lua_settop(L, c.lua_gettop(L) - 1); // pop the duplicate // Stack: ..., register_tool if (c.luaL_loadfilex(L, path_z.ptr, null) != 0) { logTopAsError(L, "lua: failed to load tool"); c.lua_settop(L, c.lua_gettop(L) - 2); // pop err + register_tool return error.LuaLoadFailed; } if (c.lua_pcallk(L, 0, 1, 0, 0, null) != 0) { logTopAsError(L, "lua: failed to run tool"); c.lua_settop(L, c.lua_gettop(L) - 2); // pop err + register_tool return error.LuaRunFailed; } // Stack: ..., register_tool, returned_value if (c.lua_type(L, -1) != lua_bridge.T_TABLE) { c.lua_settop(L, c.lua_gettop(L) - 2); // pop both std.log.err( "lua: tool script '{s}' must return a table", .{script_path}, ); return error.BadRegistration; } // Invoke register_tool(returned_table). Same validation, schema // serialization, and registrations-table append logic as an // extension's `panto.register_tool` call. if (c.lua_pcallk(L, 1, 0, 0, 0, null) != 0) { logTopAsError(L, "lua: register_tool failed for tool script"); return error.LuaRunFailed; } try self.harvestAndStoreHandlers(); } /// Walk the registrations table that the script just populated. /// For each entry: /// - Copy `name`, `description`, `schema_json` into owned bytes. /// - Pop the `handler` function and `luaL_ref` it into the /// registry; record the ref under `handlers[name]`. /// - Append a `ToolDecl` to `self.decls`. fn harvestAndStoreHandlers(self: *LuaRuntime) !void { const L = self.L; // Push the registrations table onto the stack. _ = c.lua_rawgetp(L, lua_bridge.LUA_REGISTRYINDEX, &lua_bridge.registrations_key); defer c.lua_settop(L, c.lua_gettop(L) - 1); 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 record // record at -1; bridge's records are 4-field tables. const name = try self.readStringFieldOwned("name"); errdefer { // If anything below fails after the name was added to // strings, the global deinit still cleans up; nothing // extra to undo here for the string itself. But we // *do* need to make sure the handlers map and decls // remain consistent. We allocate after the string adds, // so partial state is "string captured but no decl" // — harmless. } const desc = try self.readStringFieldOwned("description"); const schema = try self.readStringFieldOwned("schema_json"); // Pop handler function -> luaL_ref into the registry. _ = c.lua_getfield(L, -1, "handler"); if (c.lua_type(L, -1) != lua_bridge.T_FUNCTION) { c.lua_settop(L, c.lua_gettop(L) - 2); // pop handler + record return RuntimeError.LuaHandlerNotFound; } const ref = c.luaL_ref(L, lua_bridge.LUA_REGISTRYINDEX); // Stack: ..., regs_table, record const decl: panto.ToolDecl = .{ .name = name, .description = desc, .schema_json = schema, }; // Duplicate names within the runtime are not allowed — // libpanto will also catch them at registry insertion, but // we want a Lua-side error before we've started talking to // libpanto. const gop = try self.handlers.getOrPut(name); if (gop.found_existing) { c.luaL_unref(L, lua_bridge.LUA_REGISTRYINDEX, ref); c.lua_settop(L, c.lua_gettop(L) - 1); // pop record return error.DuplicateTool; } gop.value_ptr.* = ref; try self.decls.append(decl); c.lua_settop(L, c.lua_gettop(L) - 1); // pop record } } fn readStringFieldOwned(self: *LuaRuntime, field_name: [:0]const u8) ![]const u8 { const L = self.L; _ = c.lua_getfield(L, -1, field_name.ptr); defer c.lua_settop(L, c.lua_gettop(L) - 1); if (c.lua_type(L, -1) != lua_bridge.T_STRING) return error.BadRegistration; var len: usize = 0; const ptr = c.lua_tolstring(L, -1, &len); if (ptr == null) return error.BadRegistration; const owned = try self.allocator.dupe(u8, ptr[0..len]); try self.strings.append(owned); return owned; } /// Build a `ToolSource` that hands `invoke_batch` calls back to /// this runtime. The source's `ctx` is `self`. The runtime keeps /// ownership of `self`'s allocation; libpanto's registry only /// frees `ctx` via the source's `vtable.deinit` (which we make a /// no-op — the runtime is owned by the embedder). /// /// Callers must keep the LuaRuntime alive at least as long as the /// registry holds the source. pub fn toolSource(self: *LuaRuntime) panto.ToolSource { return .{ .name = SOURCE_NAME, .tools = self.decls.items, .ctx = self, .vtable = &source_vtable, }; } /// Number of tools currently declared by extensions loaded into /// this runtime. pub fn toolCount(self: *const LuaRuntime) usize { return self.decls.items.len; } }; const source_vtable: panto.ToolSource.VTable = .{ .invoke_batch = invokeBatch, .deinit = deinitSrc, }; fn deinitSrc(_: *anyopaque, _: Allocator) void { // The runtime is owned by the embedder (main()). It explicitly // calls `runtime.deinit()` after the agent has been torn down. // libpanto's source.deinit here is a no-op. } fn invokeBatch( ctx: *anyopaque, calls: []const panto.ToolCall, results: []panto.ToolCallResult, allocator: Allocator, ) anyerror!void { const self: *LuaRuntime = @ptrCast(@alignCast(ctx)); // Step 2 of LUA_MAKEOVER.md: no batteries yet — each call is run // as a coroutine, but the scheduler doesn't drive an event loop. // A handler that yields with no batteries available has nothing // to wake it; we surface that as `LuaHandlerYielded`. // // Once `luv` and the `coro-*` wrappers are installed, this loop // becomes "drive uv.run() until every coroutine is dead/erroring, // then collect results". for (calls, 0..) |call, i| { results[i] = runOneCall(self, call, allocator); } } fn runOneCall( self: *LuaRuntime, call: panto.ToolCall, allocator: Allocator, ) panto.ToolCallResult { const handler_ref = self.handlers.get(call.tool_name) orelse { return .{ .err = RuntimeError.LuaHandlerNotFound }; }; const out_bytes = invokeCoroutine(self.L, handler_ref, call.input, allocator) catch |e| { return .{ .err = e }; }; return .{ .ok = out_bytes }; } /// Create a fresh coroutine, push the handler + JSON-decoded input as /// the resume args, then resume. Returns the handler's return value as /// owned JSON bytes (the slot `ok` in CallResult). /// /// Resume outcomes: /// - LUA_OK: coroutine returned. Read its top value as the result. /// - LUA_YIELD: coroutine yielded. With no event loop installed, we /// treat this as an error so the user sees that their handler is /// trying to do async I/O that isn't yet supported. /// - other (errors): error message is on the coroutine's stack; /// copy it to a log line and return LuaHandlerCrashed. fn invokeCoroutine( L: *c.lua_State, handler_ref: c_int, input: []const u8, allocator: Allocator, ) ![]u8 { // Create the coroutine thread. After this, `co` is the child // thread; the parent stack also gains a thread value at the top. const co = c.lua_newthread(L) orelse return RuntimeError.LuaInitFailed; defer c.lua_settop(L, c.lua_gettop(L) - 1); // pop the thread when done // Push handler from the registry onto the coroutine's stack. _ = c.lua_rawgeti(co, lua_bridge.LUA_REGISTRYINDEX, @intCast(handler_ref)); if (c.lua_type(co, -1) != lua_bridge.T_FUNCTION) { return RuntimeError.LuaHandlerNotFound; } // Push the parsed JSON input as the resume arg. var arena_state = std.heap.ArenaAllocator.init(allocator); defer arena_state.deinit(); try lua_bridge.pushJsonAsLua(co, arena_state.allocator(), input); // Resume with 1 arg. var nresults: c_int = 0; const status = c.lua_resume(co, L, 1, &nresults); switch (status) { c.LUA_OK => { // Coroutine returned. We expect exactly one string return // value (the tool result). If there are zero or extra // values we still try to read top-of-stack. if (nresults < 1) return RuntimeError.BadHandlerReturn; return try lua_bridge.readHandlerResult(co, -1, allocator); }, c.LUA_YIELD => { // Nothing to wake this coroutine without an event loop. // Surface the situation so the user knows what's wrong. const msg = "lua: tool handler yielded with no event loop installed (step 4+ of LUA_MAKEOVER.md not yet implemented)"; if (@import("builtin").is_test) { std.log.warn("{s}", .{msg}); } else { std.log.err("{s}", .{msg}); } return RuntimeError.LuaHandlerYielded; }, else => { logTopAsError(co, "lua: handler crashed"); return RuntimeError.LuaHandlerCrashed; }, } } // --------------------------------------------------------------------------- // Internals // --------------------------------------------------------------------------- fn prependPackagePath(L: *c.lua_State, root: [:0]const u8) !void { const snippet = \\local root = ... \\package.path = root .. "/?.lua;" .. root .. "/?/init.lua;" .. package.path ; if (c.luaL_loadstring(L, snippet) != 0) { logTopAsError(L, "lua: package.path loader failed to compile"); return error.LuaPackagePathLoadFailed; } _ = c.lua_pushlstring(L, root.ptr, root.len); if (c.lua_pcallk(L, 1, 0, 0, 0, null) != 0) { logTopAsError(L, "lua: package.path setup failed"); return error.LuaPackagePathSetupFailed; } } 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}); } } } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- const testing = std.testing; fn writeTempScript(dir: Io.Dir, name: []const u8, source: []const u8) ![]const u8 { try dir.writeFile(testing.io, .{ .sub_path = name, .data = source }); 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 records tool decls" { 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 rt = try LuaRuntime.create(testing.allocator); defer rt.deinit(); try rt.loadExtension(path, null); try testing.expectEqual(@as(usize, 1), rt.toolCount()); try testing.expectEqualStrings("greet", rt.decls.items[0].name); } test "invokeBatch runs each call through a coroutine and returns the result" { var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); const source = \\panto.register_tool { \\ name = "echo", description = "echoes", \\ schema = { type = "object", properties = { msg = { type = "string" } } }, \\ handler = function(input) return "got: " .. input.msg end, \\} \\panto.register_tool { \\ name = "shout", description = "shouts", \\ schema = { type = "object", properties = { msg = { type = "string" } } }, \\ handler = function(input) return input.msg:upper() .. "!" end, \\} ; const path = try writeTempScript(tmp.dir, "ext.lua", source); defer testing.allocator.free(path); var rt = try LuaRuntime.create(testing.allocator); defer rt.deinit(); try rt.loadExtension(path, null); var src = rt.toolSource(); const calls = [_]panto.ToolCall{ .{ .tool_name = "echo", .input = "{\"msg\":\"hello\"}" }, .{ .tool_name = "shout", .input = "{\"msg\":\"hi\"}" }, .{ .tool_name = "echo", .input = "{\"msg\":\"again\"}" }, }; var results: [3]panto.ToolCallResult = .{ .{ .err = error.SourceDroppedCall }, .{ .err = error.SourceDroppedCall }, .{ .err = error.SourceDroppedCall }, }; try src.vtable.invoke_batch(src.ctx, &calls, &results, testing.allocator); defer for (results) |r| switch (r) { .ok => |b| testing.allocator.free(b), .err => {}, }; try testing.expectEqualStrings("got: hello", results[0].ok); try testing.expectEqualStrings("HI!", results[1].ok); try testing.expectEqualStrings("got: again", results[2].ok); } test "module-global state survives across calls in the same runtime" { // This is the headline reason the runtime exists. Verify it. var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); const source = \\local count = 0 \\panto.register_tool { \\ name = "bump", description = "increment counter", \\ schema = { type = "object" }, \\ handler = function(input) \\ count = count + 1 \\ return tostring(count) \\ end, \\} ; const path = try writeTempScript(tmp.dir, "counter.lua", source); defer testing.allocator.free(path); var rt = try LuaRuntime.create(testing.allocator); defer rt.deinit(); try rt.loadExtension(path, null); var src = rt.toolSource(); const calls = [_]panto.ToolCall{ .{ .tool_name = "bump", .input = "{}" }, .{ .tool_name = "bump", .input = "{}" }, .{ .tool_name = "bump", .input = "{}" }, }; var results: [3]panto.ToolCallResult = .{ .{ .err = error.SourceDroppedCall }, .{ .err = error.SourceDroppedCall }, .{ .err = error.SourceDroppedCall }, }; try src.vtable.invoke_batch(src.ctx, &calls, &results, testing.allocator); defer for (results) |r| switch (r) { .ok => |b| testing.allocator.free(b), .err => {}, }; try testing.expectEqualStrings("1", results[0].ok); try testing.expectEqualStrings("2", results[1].ok); try testing.expectEqualStrings("3", results[2].ok); // And a second batch keeps the counter going. var more: [1]panto.ToolCallResult = .{.{ .err = error.SourceDroppedCall }}; try src.vtable.invoke_batch( src.ctx, &[_]panto.ToolCall{.{ .tool_name = "bump", .input = "{}" }}, &more, testing.allocator, ); defer testing.allocator.free(more[0].ok); try testing.expectEqualStrings("4", more[0].ok); } test "handler crash: per-call error surfaces, sibling calls succeed" { var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); const source = \\panto.register_tool { \\ name = "ok", description = "ok", \\ schema = { type = "object" }, \\ handler = function(input) return "fine" end, \\} \\panto.register_tool { \\ name = "boom", description = "bad", \\ schema = { type = "object" }, \\ handler = function(input) error("kaboom") end, \\} ; const path = try writeTempScript(tmp.dir, "mix.lua", source); defer testing.allocator.free(path); var rt = try LuaRuntime.create(testing.allocator); defer rt.deinit(); try rt.loadExtension(path, null); var src = rt.toolSource(); const calls = [_]panto.ToolCall{ .{ .tool_name = "ok", .input = "{}" }, .{ .tool_name = "boom", .input = "{}" }, .{ .tool_name = "ok", .input = "{}" }, }; var results: [3]panto.ToolCallResult = .{ .{ .err = error.SourceDroppedCall }, .{ .err = error.SourceDroppedCall }, .{ .err = error.SourceDroppedCall }, }; try src.vtable.invoke_batch(src.ctx, &calls, &results, testing.allocator); defer for (results) |r| switch (r) { .ok => |b| testing.allocator.free(b), .err => {}, }; try testing.expectEqualStrings("fine", results[0].ok); try testing.expectEqual(@as(anyerror, RuntimeError.LuaHandlerCrashed), results[1].err); try testing.expectEqualStrings("fine", results[2].ok); } test "directory-style extension can require sibling modules" { var tmp = testing.tmpDir(.{ .iterate = true }); defer tmp.cleanup(); try tmp.dir.createDirPath(testing.io, "ext"); try tmp.dir.writeFile(testing.io, .{ .sub_path = "ext/util.lua", .data = \\local M = {} \\function M.shout(s) return s:upper() .. "!" end \\return M , }); try tmp.dir.writeFile(testing.io, .{ .sub_path = "ext/init.lua", .data = \\local util = require("util") \\panto.register_tool { \\ name = "shout", description = "uppercase + bang", \\ schema = { type = "object", properties = { text = { type = "string" } } }, \\ handler = function(input) return util.shout(input.text) end, \\} , }); var path_buf: [std.fs.max_path_bytes]u8 = undefined; const ext_len = try tmp.dir.realPathFile(testing.io, "ext", &path_buf); const ext_dir = try testing.allocator.dupe(u8, path_buf[0..ext_len]); defer testing.allocator.free(ext_dir); const init_path = try std.fs.path.join(testing.allocator, &.{ ext_dir, "init.lua" }); defer testing.allocator.free(init_path); var rt = try LuaRuntime.create(testing.allocator); defer rt.deinit(); try rt.loadExtension(init_path, ext_dir); var src = rt.toolSource(); const calls = [_]panto.ToolCall{.{ .tool_name = "shout", .input = "{\"text\":\"hi\"}" }}; var results: [1]panto.ToolCallResult = .{.{ .err = error.SourceDroppedCall }}; try src.vtable.invoke_batch(src.ctx, &calls, &results, testing.allocator); defer testing.allocator.free(results[0].ok); try testing.expectEqualStrings("HI!", results[0].ok); } test "yielding handler with no event loop surfaces LuaHandlerYielded" { var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); const source = \\panto.register_tool { \\ name = "sleeper", description = "yields forever", \\ schema = { type = "object" }, \\ handler = function(input) coroutine.yield() ; return "never" end, \\} ; const path = try writeTempScript(tmp.dir, "y.lua", source); defer testing.allocator.free(path); var rt = try LuaRuntime.create(testing.allocator); defer rt.deinit(); try rt.loadExtension(path, null); var src = rt.toolSource(); const calls = [_]panto.ToolCall{.{ .tool_name = "sleeper", .input = "{}" }}; var results: [1]panto.ToolCallResult = .{.{ .err = error.SourceDroppedCall }}; try src.vtable.invoke_batch(src.ctx, &calls, &results, testing.allocator); try testing.expectEqual(@as(anyerror, RuntimeError.LuaHandlerYielded), results[0].err); } test "loadExtension: duplicate tool name from a second extension errors" { var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); const a = \\panto.register_tool { \\ name = "clash", description = "a", \\ schema = { type = "object" }, \\ handler = function(input) return "a" end, \\} ; const b = \\panto.register_tool { \\ name = "clash", description = "b", \\ schema = { type = "object" }, \\ handler = function(input) return "b" end, \\} ; const pa = try writeTempScript(tmp.dir, "a.lua", a); defer testing.allocator.free(pa); const pb = try writeTempScript(tmp.dir, "b.lua", b); defer testing.allocator.free(pb); var rt = try LuaRuntime.create(testing.allocator); defer rt.deinit(); try rt.loadExtension(pa, null); try testing.expectError(error.DuplicateTool, rt.loadExtension(pb, null)); }