From 1f0915edbe0213e8bc134922f10933468d35a172 Mon Sep 17 00:00:00 2001 From: T Date: Tue, 26 May 2026 20:14:37 -0600 Subject: finish lua runtime makeover - new multi-tool registration via ToolSource - thread per source-or-standalone-tool - switched to zig 0.16 Io threading interface - cli: include `luv` package and run concurrent lua tools via libuv - one single long-lived lua_State for the whole cli program --- src/lua_runtime.zig | 678 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 678 insertions(+) create mode 100644 src/lua_runtime.zig (limited to 'src/lua_runtime.zig') diff --git a/src/lua_runtime.zig b/src/lua_runtime.zig new file mode 100644 index 0000000..44b64a4 --- /dev/null +++ b/src/lua_runtime.zig @@ -0,0 +1,678 @@ +//! 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(); + } + + /// 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)); +} -- cgit v1.3