diff options
| author | T <t@tjp.lol> | 2026-05-26 20:14:37 -0600 |
|---|---|---|
| committer | T <t@tjp.lol> | 2026-05-27 06:26:36 -0600 |
| commit | 1f0915edbe0213e8bc134922f10933468d35a172 (patch) | |
| tree | 11834769555c7037f3393ef8d98bf5841846144a /src | |
| parent | b788eb05c6d194b91fdc141b6655e61ccaa76ddb (diff) | |
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
Diffstat (limited to 'src')
| -rw-r--r-- | src/extension_loader.zig | 216 | ||||
| -rw-r--r-- | src/lua_bridge.zig | 53 | ||||
| -rw-r--r-- | src/lua_runtime.zig | 678 | ||||
| -rw-r--r-- | src/lua_tool.zig | 418 | ||||
| -rw-r--r-- | src/main.zig | 19 | ||||
| -rw-r--r-- | src/ping_tool.zig | 8 |
6 files changed, 774 insertions, 618 deletions
diff --git a/src/extension_loader.zig b/src/extension_loader.zig index 158e6b6..2a35d15 100644 --- a/src/extension_loader.zig +++ b/src/extension_loader.zig @@ -1,38 +1,34 @@ //! Extension discovery: walk well-known directories, locate Lua extensions, -//! and register their tools with a `ToolRegistry`. +//! and load each one into a long-lived `LuaRuntime`. //! //! Search order (later entries shadow earlier ones by extension *name*): //! 1. `${XDG_CONFIG_HOME:-$HOME/.config}/panto/extensions/` ("user") //! 2. `./.panto/extensions/` ("project") //! -//! Layout per directory: each entry is either +//! Layout per directory: //! - `<name>.lua` -- single-file extension; the extension name is //! the basename without the `.lua` suffix. -//! - `<name>/init.lua` -- directory extension; the extension name is the -//! directory name. The directory is added to the -//! extension's `package.path` so it can `require` -//! sibling Lua files. +//! - `<name>/init.lua` -- directory extension; the extension name is +//! the directory name. The directory is added +//! to the extension's `package.path` so it can +//! `require` sibling Lua files. //! //! Conflict rules: -//! - Within a single directory, two entries with the same extension name +//! - Within one directory, two entries with the same extension name //! are an error. //! - Project shadows user by extension name (debug-logged, not an error). -//! - Tool-name collisions *between* loaded extensions are an error: a tool -//! name is a contract the LLM relies on, and surprising overrides at -//! load time are worse than failing fast. +//! - Tool-name collisions *between* loaded extensions are an error: a +//! tool name is a contract the LLM relies on. //! -//! Symlinks: followed normally (no special handling). -//! Hidden files: dotfiles (`.foo.lua`) are skipped to leave room for editor -//! swap files and the like. +//! Symlinks: followed normally. Dotfiles: skipped. const std = @import("std"); const panto = @import("panto"); -const lua_tool = @import("lua_tool.zig"); -const lua_bridge = @import("lua_bridge.zig"); +const lua_runtime = @import("lua_runtime.zig"); -const c = lua_bridge.c; const Allocator = std.mem.Allocator; const Io = std.Io; +const LuaRuntime = lua_runtime.LuaRuntime; /// A discovered extension before loading. Owns its strings. const Found = struct { @@ -40,11 +36,9 @@ const Found = struct { name: []u8, /// Absolute path to the Lua script to execute. script_path: []u8, - /// For directory-style extensions, the directory containing the script - /// (used to extend `package.path`). Null for single-file extensions. + /// For directory-style extensions, the directory containing the script. package_root: ?[]u8, - /// Which search-path source this came from. Informational, used for - /// shadowing log messages and tool-conflict error context. + /// Which search-path source this came from. source: Source, pub fn deinit(self: *Found, allocator: Allocator) void { @@ -66,17 +60,16 @@ pub const Source = enum { } }; -/// Discover and register every extension found in the standard paths -/// derived from the environment + current working directory. Returns the -/// number of *tools* (not extensions) registered. +/// Discover and load every extension found in the standard paths into +/// `runtime`. Returns the number of *tools* (not extensions) declared. /// /// `environ_map` is consulted for `HOME` and `XDG_CONFIG_HOME`. The -/// project directory is always taken as `cwd()/.panto/extensions`. +/// project directory is always `cwd()/.panto/extensions`. pub fn discoverAndLoad( allocator: Allocator, io: Io, environ_map: *const std.process.Environ.Map, - registry: *panto.ToolRegistry, + runtime: *LuaRuntime, ) !usize { const user_dir = try userExtensionsDir(allocator, environ_map); defer if (user_dir) |d| allocator.free(d); @@ -84,21 +77,18 @@ pub fn discoverAndLoad( const project_dir = try projectExtensionsDir(allocator, io); defer allocator.free(project_dir); - return loadFromDirs(allocator, io, registry, user_dir, project_dir); + return loadFromDirs(allocator, io, runtime, user_dir, project_dir); } -/// Lower-level entry point: load from explicit user/project paths. Useful -/// for tests that want deterministic behavior without touching -/// process-global state (HOME, cwd). Either path may be null; missing -/// directories on disk are silently skipped. +/// Lower-level entry point: load from explicit user/project paths. +/// Either path may be null; missing directories are silently skipped. pub fn loadFromDirs( allocator: Allocator, io: Io, - registry: *panto.ToolRegistry, + runtime: *LuaRuntime, user_dir: ?[]const u8, project_dir: ?[]const u8, ) !usize { - // 1. Collect candidates from both sources, project last so it wins. var found: std.array_list.Managed(Found) = .init(allocator); defer { for (found.items) |*f| f.deinit(allocator); @@ -108,16 +98,11 @@ pub fn loadFromDirs( if (user_dir) |d| try scanDir(allocator, io, d, .user, &found); if (project_dir) |d| try scanDir(allocator, io, d, .project, &found); - // 2. Apply project-shadows-user by name. The latest occurrence wins. try applyShadowing(allocator, &found); - // 3. Load each surviving extension. Tool-name conflicts surface as - // `ToolRegistry.register` errors and abort startup. - var total_tools: usize = 0; + const before = runtime.toolCount(); for (found.items) |f| { - const n = loadOne(allocator, registry, f) catch |err| { - // In test builds, log at warn level so a deliberate failure - // test doesn't trip the test runner's err-count check. + runtime.loadExtension(f.script_path, f.package_root) catch |err| { if (@import("builtin").is_test) { std.log.warn( "extension '{s}' ({s}: {s}) failed to load: {t}", @@ -132,21 +117,17 @@ pub fn loadFromDirs( return err; }; std.log.debug( - "extension: loaded {d} tool(s) from '{s}' ({s})", - .{ n, f.name, f.source.label() }, + "extension: loaded '{s}' ({s})", + .{ f.name, f.source.label() }, ); - total_tools += n; } - return total_tools; + return runtime.toolCount() - before; } // --------------------------------------------------------------------------- // Path resolution // --------------------------------------------------------------------------- -/// Returns the absolute path of the user extensions directory, or null if -/// `HOME` is unset and `XDG_CONFIG_HOME` is not provided either. Caller -/// owns the returned slice. fn userExtensionsDir( allocator: Allocator, environ_map: *const std.process.Environ.Map, @@ -160,8 +141,6 @@ fn userExtensionsDir( return null; } -/// Returns the absolute path of the project extensions directory. -/// Caller owns the returned slice. fn projectExtensionsDir(allocator: Allocator, io: Io) ![]u8 { const cwd = try std.process.currentPathAlloc(io, allocator); defer allocator.free(cwd); @@ -172,8 +151,6 @@ fn projectExtensionsDir(allocator: Allocator, io: Io) ![]u8 { // Directory scanning // --------------------------------------------------------------------------- -/// Scan `dir_path` and append any candidate extensions to `out`. Missing -/// directories are not an error: extensions are an optional feature. fn scanDir( allocator: Allocator, io: Io, @@ -196,7 +173,6 @@ fn scanDir( var iter = dir.iterate(); while (try iter.next(io)) |entry| { - // Skip dotfiles (editor swap files, .DS_Store, hidden dirs, ...). if (entry.name.len == 0 or entry.name[0] == '.') continue; const maybe_found: ?Found = switch (entry.kind) { @@ -206,8 +182,6 @@ fn scanDir( }; const f = maybe_found orelse continue; - // Within one directory, duplicate names are an error. (Can happen - // if both `foo.lua` and `foo/init.lua` exist.) const gop = try local_names.getOrPut(f.name); if (gop.found_existing) { if (@import("builtin").is_test) { @@ -221,13 +195,10 @@ fn scanDir( .{ f.name, dir_path }, ); } - // Free the duplicate's resources before bailing. var dup = f; dup.deinit(allocator); return error.DuplicateExtensionInDirectory; } - // Hash map key is borrowed from f.name; we need an independent copy - // since `out` owns f and we'd otherwise double-free. gop.key_ptr.* = try allocator.dupe(u8, f.name); try out.append(f); @@ -257,8 +228,6 @@ fn classifyFile( }; } -/// Treat `entry_name/init.lua` as a directory-style extension if present. -/// Returns null if no `init.lua` exists inside. fn classifyDirectory( allocator: Allocator, io: Io, @@ -267,13 +236,12 @@ fn classifyDirectory( entry_name: []const u8, source: Source, ) !?Found { - // Probe for init.lua before doing any allocation. var sub = parent.openDir(io, entry_name, .{}) catch return null; defer sub.close(io); sub.access(io, "init.lua", .{}) catch |err| switch (err) { error.FileNotFound => return null, - else => return null, // permission, etc. — quietly skip + else => return null, }; const package_root = try std.fs.path.join(allocator, &.{ dir_path, entry_name }); @@ -295,14 +263,6 @@ fn classifyDirectory( // Shadowing // --------------------------------------------------------------------------- -/// In-place: for every name that appears more than once, keep only the -/// *last* occurrence and drop earlier ones with a debug log. -/// -/// We do this in two passes. The first pass populates a `name → winning -/// index` map (last-write-wins by construction). The second pass walks -/// the list once, partitioning into kept and dropped sets *without* -/// freeing strings yet — the map still references them via its keys, so -/// freeing mid-walk would create dangling keys in the hash table. fn applyShadowing(allocator: Allocator, list: *std.array_list.Managed(Found)) !void { var latest: std.StringHashMap(usize) = .init(allocator); @@ -335,52 +295,25 @@ fn applyShadowing(allocator: Allocator, list: *std.array_list.Managed(Found)) !v } } - // The map's keys still alias `drop`'s entries; deinit it first so we - // can then free those entries' strings without leaving dangling keys. latest.deinit(); for (drop.items) |*f| f.deinit(allocator); drop.deinit(); - // Replace list contents without re-freeing the entries we kept. list.clearRetainingCapacity(); list.appendSlice(keep.items) catch unreachable; keep.deinit(); } // --------------------------------------------------------------------------- -// Loading -// --------------------------------------------------------------------------- - -/// Load one discovered extension and register its tools. Returns the -/// number of tools registered. -fn loadOne( - allocator: Allocator, - registry: *panto.ToolRegistry, - found: Found, -) !usize { - if (found.package_root) |root| { - return lua_tool.loadExtensionWithPackageRoot( - allocator, - registry, - found.script_path, - root, - ); - } - return lua_tool.loadExtension(allocator, registry, found.script_path); -} - -// --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- const testing = std.testing; -/// Helper: write a single file inside `dir` at `sub_path`. fn writeFile(dir: Io.Dir, sub_path: []const u8, content: []const u8) !void { try dir.writeFile(testing.io, .{ .sub_path = sub_path, .data = content }); } -/// Helper: create a directory (path may contain separators). fn makeDir(dir: Io.Dir, sub_path: []const u8) !void { try dir.createDirPath(testing.io, sub_path); } @@ -389,14 +322,6 @@ test "scanDir picks up single-file and directory-style extensions" { var tmp = testing.tmpDir(.{ .iterate = true }); defer tmp.cleanup(); - // Layout: - // ext_root/ - // alpha.lua - // beta/ - // init.lua - // helper.lua - // .ignored.lua (dotfile -> skipped) - // readme.txt (non-.lua -> skipped) try makeDir(tmp.dir, "ext_root"); try makeDir(tmp.dir, "ext_root/beta"); try writeFile(tmp.dir, "ext_root/alpha.lua", "-- alpha\n"); @@ -405,7 +330,6 @@ test "scanDir picks up single-file and directory-style extensions" { try writeFile(tmp.dir, "ext_root/.ignored.lua", "-- hidden\n"); try writeFile(tmp.dir, "ext_root/readme.txt", "noise\n"); - // Absolute path to ext_root. var path_buf: [std.fs.max_path_bytes]u8 = undefined; const ext_root_len = try tmp.dir.realPathFile(testing.io, "ext_root", &path_buf); const ext_root = path_buf[0..ext_root_len]; @@ -419,7 +343,6 @@ test "scanDir picks up single-file and directory-style extensions" { try testing.expectEqual(@as(usize, 2), list.items.len); - // Order is filesystem-dependent; sort by name for stable assertions. std.mem.sort(Found, list.items, {}, struct { fn lt(_: void, a: Found, b: Found) bool { return std.mem.lessThan(u8, a.name, b.name); @@ -439,7 +362,6 @@ test "duplicate name in same directory is an error" { var tmp = testing.tmpDir(.{ .iterate = true }); defer tmp.cleanup(); - // Both `foo.lua` and `foo/init.lua` exist. try makeDir(tmp.dir, "ext_root/foo"); try writeFile(tmp.dir, "ext_root/foo.lua", "-- single\n"); try writeFile(tmp.dir, "ext_root/foo/init.lua", "-- dir\n"); @@ -482,7 +404,6 @@ test "applyShadowing keeps the latest occurrence" { try applyShadowing(testing.allocator, &list); try testing.expectEqual(@as(usize, 3), list.items.len); - // "shared" survives once, from the project source. var shared_count: usize = 0; var shared_source: ?Source = null; for (list.items) |f| { @@ -495,7 +416,7 @@ test "applyShadowing keeps the latest occurrence" { try testing.expectEqual(@as(?Source, .project), shared_source); } -test "loadFromDirs: project shadows user end-to-end" { +test "loadFromDirs: project shadows user end-to-end (via long-lived runtime)" { var tmp = testing.tmpDir(.{ .iterate = true }); defer tmp.cleanup(); @@ -526,66 +447,19 @@ test "loadFromDirs: project shadows user end-to-end" { const proj_path = try testing.allocator.dupe(u8, path_buf[0..proj_len]); defer testing.allocator.free(proj_path); - var registry = panto.ToolRegistry.init(testing.allocator); - defer registry.deinit(); + var rt = try LuaRuntime.create(testing.allocator); + defer rt.deinit(); - const n_tools = try loadFromDirs( - testing.allocator, - testing.io, - ®istry, - user_path, - proj_path, - ); + const n_tools = try loadFromDirs(testing.allocator, testing.io, rt, user_path, proj_path); try testing.expectEqual(@as(usize, 1), n_tools); - const tool = registry.lookup("greet") orelse return error.NotRegistered; - try testing.expectEqualStrings("project version", tool.description); - - const out = try tool.vtable.invoke(tool.ctx, "{}", testing.allocator); - defer testing.allocator.free(out); - try testing.expectEqualStrings("PROJECT", out); -} - -test "loadFromDirs: directory-style extension can require siblings" { - var tmp = testing.tmpDir(.{ .iterate = true }); - defer tmp.cleanup(); - - try makeDir(tmp.dir, "ext/composer"); - try writeFile(tmp.dir, "ext/composer/util.lua", - \\local M = {} - \\function M.shout(s) return s:upper() .. "!" end - \\return M - ); - try writeFile(tmp.dir, "ext/composer/init.lua", - \\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_path = try testing.allocator.dupe(u8, path_buf[0..ext_len]); - defer testing.allocator.free(ext_path); - - var registry = panto.ToolRegistry.init(testing.allocator); - defer registry.deinit(); - - const n = try loadFromDirs( - testing.allocator, - testing.io, - ®istry, - null, - ext_path, - ); - try testing.expectEqual(@as(usize, 1), n); - - const tool = registry.lookup("shout") orelse return error.NotRegistered; - const out = try tool.vtable.invoke(tool.ctx, "{\"text\":\"hi\"}", testing.allocator); - defer testing.allocator.free(out); - try testing.expectEqualStrings("HI!", out); + // Invoke the tool through the source and verify the project handler ran. + var src = rt.toolSource(); + const calls = [_]panto.ToolCall{.{ .tool_name = "greet", .input = "{}" }}; + 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("PROJECT", results[0].ok); } test "loadFromDirs: tool-name collision between extensions errors" { @@ -613,15 +487,9 @@ test "loadFromDirs: tool-name collision between extensions errors" { const ext_path = try testing.allocator.dupe(u8, path_buf[0..n]); defer testing.allocator.free(ext_path); - var registry = panto.ToolRegistry.init(testing.allocator); - defer registry.deinit(); + var rt = try LuaRuntime.create(testing.allocator); + defer rt.deinit(); - const result = loadFromDirs( - testing.allocator, - testing.io, - ®istry, - null, - ext_path, - ); + const result = loadFromDirs(testing.allocator, testing.io, rt, null, ext_path); try testing.expectError(error.DuplicateTool, result); } diff --git a/src/lua_bridge.zig b/src/lua_bridge.zig index 3d4a24f..eca5661 100644 --- a/src/lua_bridge.zig +++ b/src/lua_bridge.zig @@ -1,7 +1,7 @@ //! Lua C-API bridge for the panto CLI. //! -//! Exposes a `panto` global table inside any `lua_State` we construct, with -//! a single function: +//! Exposes a `panto` global table inside any `lua_State` we construct, +//! with a single function: //! //! panto.register_tool { //! name = "...", @@ -10,26 +10,29 @@ //! 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. +//! 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: +//! Each call records a registration in a Lua-side table at a fixed +//! registry slot (`registrations_key`). The Zig side reads that table +//! to decide what to do with the entries: //! -//! - **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. +//! - The long-lived `LuaRuntime` (`src/lua_runtime.zig`) loads each +//! extension into a single `lua_State`, then walks the registrations +//! table once per loaded script (between loads it calls +//! `resetRegistrations`). For each entry it copies name / +//! description / schema, `luaL_ref`s the handler function into the +//! Lua registry, and records the ref under the tool name. //! -//! - **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. +//! - On dispatch, the runtime spawns a coroutine, pushes the handler +//! onto it via `lua_rawgeti(LUA_REGISTRYINDEX, ref)`, pushes the +//! parsed JSON input, and `lua_resume`s. Top-level extension code +//! never runs again — only handler bodies. //! -//! 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. +//! This bridge module itself is stateless beyond the public +//! `registrations_key` address; it cooperates with whatever runtime +//! owns the `lua_State`. const std = @import("std"); const Allocator = std.mem.Allocator; @@ -69,7 +72,10 @@ pub const BridgeError = error{ /// 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; +/// +/// Public so the long-lived runtime can poke at it directly when it +/// needs to harvest entries. +pub 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 @@ -103,6 +109,15 @@ pub fn install(L: *c.lua_State) void { c.lua_setglobal(L, "panto"); } +/// Replace the registrations table with a fresh empty one. Used by the +/// long-lived runtime between loading distinct extension scripts so it +/// can harvest only the registrations made by the script just loaded +/// (not accumulated from prior loads). +pub fn resetRegistrations(L: *c.lua_State) void { + c.lua_createtable(L, 0, 0); + c.lua_rawsetp(L, LUA_REGISTRYINDEX, ®istrations_key); +} + /// 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. 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)); +} diff --git a/src/lua_tool.zig b/src/lua_tool.zig deleted file mode 100644 index 21b3e51..0000000 --- a/src/lua_tool.zig +++ /dev/null @@ -1,418 +0,0 @@ -//! `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, - /// Optional directory to prepend to `package.path` so the script can - /// `require` sibling files. Owned, NUL-terminated. Null for single-file - /// extensions. - package_root_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, - package_root: ?[]const u8, - ) !panto.Tool { - const self = try allocator.create(LuaTool); - errdefer allocator.destroy(self); - - const script_path_z = try allocator.dupeZ(u8, script_path); - errdefer allocator.free(script_path_z); - const name_owned = try allocator.dupe(u8, name); - errdefer allocator.free(name_owned); - const description_owned = try allocator.dupe(u8, description); - errdefer allocator.free(description_owned); - const schema_owned = try allocator.dupe(u8, schema_json); - errdefer allocator.free(schema_owned); - const package_root_z: ?[:0]u8 = if (package_root) |p| - try allocator.dupeZ(u8, p) - else - null; - - self.* = .{ - .allocator = allocator, - .script_path_z = script_path_z, - .package_root_z = package_root_z, - .name_owned = name_owned, - .description_owned = description_owned, - .schema_owned = schema_owned, - }; - - 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); - if (self.package_root_z) |p| a.free(p); - 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); - - if (self.package_root_z) |root| { - try prependPackagePath(L, root); - } - - // 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 { - return loadExtensionImpl(allocator, registry, script_path, null); -} - -/// Like `loadExtension`, but extends `package.path` with `<package_root>/?.lua` -/// and `<package_root>/?/init.lua` so the script can `require` sibling files. -/// `package_root` should be the directory containing the script. -pub fn loadExtensionWithPackageRoot( - allocator: Allocator, - registry: *panto.ToolRegistry, - script_path: []const u8, - package_root: []const u8, -) !usize { - return loadExtensionImpl(allocator, registry, script_path, package_root); -} - -fn loadExtensionImpl( - allocator: Allocator, - registry: *panto.ToolRegistry, - script_path: []const u8, - package_root: ?[]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); - - // Configure require() for directory-style extensions before running. - if (package_root) |root| { - const root_z = try allocator.dupeZ(u8, root); - defer allocator.free(root_z); - try prependPackagePath(L, root_z); - } - - 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, - package_root, - ); - // 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; -} - -/// Prepend `<root>/?.lua` and `<root>/?/init.lua` to `package.path` so that -/// `require("foo")` resolves against files next to the extension's -/// `init.lua`. We prepend (not replace) so the standard library remains -/// available. -/// -/// Stack effect: net zero. -fn prependPackagePath(L: *c.lua_State, root: [:0]const u8) !void { - // Run a tiny Lua snippet rather than fiddle with the stack manually: - // string concatenation is so much nicer in Lua. - 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; - } -} - -// --------------------------------------------------------------------------- -// 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])); - } - } -} diff --git a/src/main.zig b/src/main.zig index 56ea2ad..a752a12 100644 --- a/src/main.zig +++ b/src/main.zig @@ -2,7 +2,7 @@ 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"); +const lua_runtime = @import("lua_runtime.zig"); const extension_loader = @import("extension_loader.zig"); // Shorthand alias for the Lua C API. The bridge module owns the actual @@ -12,7 +12,7 @@ const lua = lua_bridge.c; test { std.testing.refAllDecls(@This()); _ = lua_bridge; - _ = lua_tool; + _ = lua_runtime; _ = extension_loader; } @@ -220,13 +220,20 @@ pub fn main(init: std.process.Init) !void { try conv.addSystemMessage("You are a helpful assistant."); const prov = try panto.provider.Provider.init(alloc, io, config); - var agent = panto.agent.Agent.init(alloc, prov); + var agent = panto.agent.Agent.init(alloc, io, prov); defer agent.deinit(); // smoke test: register a trivial built-in tool so we can exercise // the tool-call loop against a real LLM. try agent.registerTool(ping_tool.tool()); + // Spin up the long-lived Lua runtime. All Lua extensions load into + // one `lua_State`; module-global state survives across calls. The + // runtime registers with the agent as a single `ToolSource` named + // `panto-lua`. + var rt = try lua_runtime.LuaRuntime.create(alloc); + defer rt.deinit(); + // Discover Lua extensions from $XDG_CONFIG_HOME/panto/extensions (or // $HOME/.config/panto/extensions) and ./.panto/extensions. Project // entries shadow user entries with the same name; tool-name collisions @@ -235,13 +242,17 @@ pub fn main(init: std.process.Init) !void { alloc, io, init.environ_map, - &agent.registry, + rt, ) catch |err| { std.log.err("extension discovery failed: {t}", .{err}); return err; }; std.log.debug("extensions: {d} tool(s) registered", .{n_ext_tools}); + if (n_ext_tools > 0) { + try agent.registerToolSource(rt.toolSource()); + } + const banner_model: []const u8 = switch (config) { inline else => |c| c.model, }; diff --git a/src/ping_tool.zig b/src/ping_tool.zig index eb212af..d18bc02 100644 --- a/src/ping_tool.zig +++ b/src/ping_tool.zig @@ -35,9 +35,11 @@ const SCHEMA_JSON = /// nothing dereferences it. pub fn tool() panto.Tool { return .{ - .name = NAME, - .description = DESCRIPTION, - .schema_json = SCHEMA_JSON, + .decl = .{ + .name = NAME, + .description = DESCRIPTION, + .schema_json = SCHEMA_JSON, + }, .ctx = &ctx_sentinel, .vtable = &vtable, }; |
