summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/extension_loader.zig216
-rw-r--r--src/lua_bridge.zig53
-rw-r--r--src/lua_runtime.zig678
-rw-r--r--src/lua_tool.zig418
-rw-r--r--src/main.zig19
-rw-r--r--src/ping_tool.zig8
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,
- &registry,
- 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,
- &registry,
- 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,
- &registry,
- 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, &registrations_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, &registry, 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, &registry, 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, &registry, 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,
};