summaryrefslogtreecommitdiff
path: root/src/lua_runtime.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/lua_runtime.zig')
-rw-r--r--src/lua_runtime.zig678
1 files changed, 678 insertions, 0 deletions
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));
+}