From f72b5793a5c7e7891e4904be73c0bc6bc038fa18 Mon Sep 17 00:00:00 2001 From: T Date: Wed, 27 May 2026 16:34:32 -0600 Subject: system agent definition scaffolding --- src/extension_loader.zig | 165 ++++++++++++++++++++++++++++++++++++++++++++--- src/luarocks_runtime.zig | 37 +++++++++++ src/main.zig | 10 +-- src/panto_home.zig | 11 ++++ 4 files changed, 210 insertions(+), 13 deletions(-) (limited to 'src') diff --git a/src/extension_loader.zig b/src/extension_loader.zig index 8a871c9..2fd86ae 100644 --- a/src/extension_loader.zig +++ b/src/extension_loader.zig @@ -1,17 +1,26 @@ //! Extension and tool discovery: walk well-known directories, locate Lua //! files, and load each into a long-lived `LuaRuntime`. //! -//! Two parallel namespaces are scanned, each at user and project scope: +//! Two parallel namespaces are scanned, each at three scopes — system, +//! user, and project. Project shadows user shadows system. //! //! Extensions (full-featured; call `panto.register_tool` from a script //! that may register many tools): -//! 1. `${XDG_CONFIG_HOME:-$HOME/.config}/panto/extensions/` ("user") -//! 2. `./.panto/extensions/` ("project") +//! 1. `$PANTO_HOME/agent/extensions/` ("system") +//! 2. `${XDG_CONFIG_HOME:-$HOME/.config}/panto/extensions/` ("user") +//! 3. `./.panto/extensions/` ("project") //! //! Tools (ergonomic single-tool form; the script returns one table //! shaped like the argument to `panto.register_tool`): -//! 1. `${XDG_CONFIG_HOME:-$HOME/.config}/panto/tools/` ("user") -//! 2. `./.panto/tools/` ("project") +//! 1. `$PANTO_HOME/agent/tools/` ("system") +//! 2. `${XDG_CONFIG_HOME:-$HOME/.config}/panto/tools/` ("user") +//! 3. `./.panto/tools/` ("project") +//! +//! The `system` layer is populated at bootstrap from files embedded +//! into the panto binary (see `build/gen_agent_embed.zig` and +//! `luarocks_runtime.stageAgentTree`). It is panto's "batteries" tier: +//! ships in the binary, but every entry is individually shadowable +//! from the user or project layer. //! //! Layout per directory (identical for extensions and tools): //! - `.lua` -- single-file entry; the logical name is the @@ -24,10 +33,11 @@ //! Conflict rules: //! - Within one directory, two entries with the same logical name //! are an error. -//! - Project shadows user *within the same kind* (extension or tool). -//! Extensions and tools live in distinct namespaces for shadowing; -//! the registered *tool names* still share one global namespace -//! across both, and collisions there are still an error. +//! - Project shadows user shadows system *within the same kind* +//! (extension or tool). Extensions and tools live in distinct +//! namespaces for shadowing; the registered *tool names* still +//! share one global namespace across both, and collisions there +//! are still an error. //! //! Symlinks: followed normally. Dotfiles: skipped. @@ -60,11 +70,15 @@ const Found = struct { }; pub const Source = enum { + /// Staged at bootstrap from files embedded in the panto binary. + /// Lowest priority — shadowed by user and project. + system, user, project, pub fn label(self: Source) []const u8 { return switch (self) { + .system => "system", .user => "user", .project => "project", }; @@ -87,14 +101,31 @@ pub const Kind = enum { /// paths into `runtime`. Returns the number of registered tools added /// to the runtime by this call. /// +/// `system_agent_dir`, when non-null, is the path under which embedded +/// system tools/extensions have been staged — typically +/// `$PANTO_HOME/agent/`. Pass `null` to skip the system layer entirely +/// (mostly useful for tests). +/// /// `environ_map` is consulted for `HOME` and `XDG_CONFIG_HOME`. The /// project directories are always `cwd()/.panto/{extensions,tools}`. pub fn discoverAndLoad( allocator: Allocator, io: Io, environ_map: *const std.process.Environ.Map, + system_agent_dir: ?[]const u8, runtime: *LuaRuntime, ) !usize { + const sys_ext = if (system_agent_dir) |d| + try std.fs.path.join(allocator, &.{ d, kindSubdir(.extension) }) + else + null; + defer if (sys_ext) |d| allocator.free(d); + const sys_tool = if (system_agent_dir) |d| + try std.fs.path.join(allocator, &.{ d, kindSubdir(.tool) }) + else + null; + defer if (sys_tool) |d| allocator.free(d); + const user_ext = try userKindDir(allocator, environ_map, .extension); defer if (user_ext) |d| allocator.free(d); const user_tool = try userKindDir(allocator, environ_map, .tool); @@ -110,8 +141,10 @@ pub fn discoverAndLoad( io, runtime, .{ + .system_extensions = sys_ext, .user_extensions = user_ext, .project_extensions = project_ext, + .system_tools = sys_tool, .user_tools = user_tool, .project_tools = project_tool, }, @@ -120,9 +153,15 @@ pub fn discoverAndLoad( /// Set of search paths consumed by `loadFromDirs`. Any field may be /// null; missing directories on disk are silently skipped. +/// +/// Scan order is system → user → project. `applyShadowing` keeps the +/// *last* occurrence of each (kind, name), so project entries win, +/// then user, then system. pub const DirSet = struct { + system_extensions: ?[]const u8 = null, user_extensions: ?[]const u8 = null, project_extensions: ?[]const u8 = null, + system_tools: ?[]const u8 = null, user_tools: ?[]const u8 = null, project_tools: ?[]const u8 = null, }; @@ -141,8 +180,10 @@ pub fn loadFromDirs( found.deinit(); } + if (dirs.system_extensions) |d| try scanDir(allocator, io, d, .system, .extension, &found); if (dirs.user_extensions) |d| try scanDir(allocator, io, d, .user, .extension, &found); if (dirs.project_extensions) |d| try scanDir(allocator, io, d, .project, .extension, &found); + if (dirs.system_tools) |d| try scanDir(allocator, io, d, .system, .tool, &found); if (dirs.user_tools) |d| try scanDir(allocator, io, d, .user, .tool, &found); if (dirs.project_tools) |d| try scanDir(allocator, io, d, .project, .tool, &found); @@ -708,6 +749,112 @@ test "loadFromDirs: project tool shadows user tool of the same name" { try testing.expectEqualStrings("PROJECT", results[0].ok); } +test "loadFromDirs: project tool shadows user shadows system" { + var tmp = testing.tmpDir(.{ .iterate = true }); + defer tmp.cleanup(); + + try makeDir(tmp.dir, "system_tools"); + try makeDir(tmp.dir, "user_tools"); + try makeDir(tmp.dir, "project_tools"); + + try writeFile(tmp.dir, "system_tools/greet.lua", + \\return { + \\ name = "greet", description = "system version", + \\ schema = { type = "object" }, + \\ handler = function(input) return "SYSTEM" end, + \\} + ); + try writeFile(tmp.dir, "user_tools/greet.lua", + \\return { + \\ name = "greet", description = "user version", + \\ schema = { type = "object" }, + \\ handler = function(input) return "USER" end, + \\} + ); + try writeFile(tmp.dir, "project_tools/greet.lua", + \\return { + \\ name = "greet", description = "project version", + \\ schema = { type = "object" }, + \\ handler = function(input) return "PROJECT" end, + \\} + ); + + var path_buf: [std.fs.max_path_bytes]u8 = undefined; + const sys_len = try tmp.dir.realPathFile(testing.io, "system_tools", &path_buf); + const sys_path = try testing.allocator.dupe(u8, path_buf[0..sys_len]); + defer testing.allocator.free(sys_path); + const user_len = try tmp.dir.realPathFile(testing.io, "user_tools", &path_buf); + const user_path = try testing.allocator.dupe(u8, path_buf[0..user_len]); + defer testing.allocator.free(user_path); + const proj_len = try tmp.dir.realPathFile(testing.io, "project_tools", &path_buf); + const proj_path = try testing.allocator.dupe(u8, path_buf[0..proj_len]); + defer testing.allocator.free(proj_path); + + var rt = try LuaRuntime.create(testing.allocator); + defer rt.deinit(); + + const n_tools = try loadFromDirs(testing.allocator, testing.io, rt, .{ + .system_tools = sys_path, + .user_tools = user_path, + .project_tools = proj_path, + }); + try testing.expectEqual(@as(usize, 1), n_tools); + + 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: user tool shadows system tool when no project entry" { + var tmp = testing.tmpDir(.{ .iterate = true }); + defer tmp.cleanup(); + + try makeDir(tmp.dir, "system_tools"); + try makeDir(tmp.dir, "user_tools"); + + try writeFile(tmp.dir, "system_tools/greet.lua", + \\return { + \\ name = "greet", description = "system", + \\ schema = { type = "object" }, + \\ handler = function(input) return "SYSTEM" end, + \\} + ); + try writeFile(tmp.dir, "user_tools/greet.lua", + \\return { + \\ name = "greet", description = "user", + \\ schema = { type = "object" }, + \\ handler = function(input) return "USER" end, + \\} + ); + + var path_buf: [std.fs.max_path_bytes]u8 = undefined; + const sys_len = try tmp.dir.realPathFile(testing.io, "system_tools", &path_buf); + const sys_path = try testing.allocator.dupe(u8, path_buf[0..sys_len]); + defer testing.allocator.free(sys_path); + const user_len = try tmp.dir.realPathFile(testing.io, "user_tools", &path_buf); + const user_path = try testing.allocator.dupe(u8, path_buf[0..user_len]); + defer testing.allocator.free(user_path); + + var rt = try LuaRuntime.create(testing.allocator); + defer rt.deinit(); + + const n_tools = try loadFromDirs(testing.allocator, testing.io, rt, .{ + .system_tools = sys_path, + .user_tools = user_path, + }); + try testing.expectEqual(@as(usize, 1), n_tools); + + 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("USER", results[0].ok); +} + test "loadFromDirs: extension and tool share a *file* name independently" { // The shadow-key is (kind, name) so an extension named `foo` and a // tool named `foo` coexist — as long as their *registered* tool names diff --git a/src/luarocks_runtime.zig b/src/luarocks_runtime.zig index b67a8ad..0114b64 100644 --- a/src/luarocks_runtime.zig +++ b/src/luarocks_runtime.zig @@ -37,6 +37,7 @@ const manifest = @import("manifest.zig"); const panto_home = @import("panto_home.zig"); const embedded_luarocks = @import("embedded_luarocks"); const embedded_lua_headers = @import("embedded_lua_headers"); +const embedded_agent = @import("embedded_agent"); const lua_bridge = @import("lua_bridge.zig"); const c = lua_bridge.c; @@ -135,6 +136,7 @@ pub fn bootstrap( try panto_home.ensureDirsExist(layout, io); try stageLuaHeaders(allocator, io, layout); + try stageAgentTree(allocator, io, layout); const lua_wrapper_path = try writeLuaWrapper(allocator, io, layout, panto_executable_path); defer allocator.free(lua_wrapper_path); try writeLuarocksConfig(allocator, io, layout, lua_wrapper_path); @@ -199,6 +201,41 @@ fn stageLuaHeaders( } } +// --------------------------------------------------------------------------- +// Stage the embedded `agent/` tree +// --------------------------------------------------------------------------- + +/// Materialize the binary-embedded `agent/` tree under +/// `$PANTO_HOME/agent/`. Each entry's `path` is relative to the agent +/// root and may include subdirectories (`tools/read.lua` etc.); the +/// parent directory is created lazily as we encounter files inside it. +/// +/// Uses the same `writeIfDifferent` discipline as `stageLuaHeaders`: +/// unchanged files keep their on-disk mtime so downstream consumers +/// (the extension loader's stat-cached searches, future-rocks builds +/// against staged headers) don't see spurious invalidations. +fn stageAgentTree( + allocator: Allocator, + io: Io, + layout: panto_home.Layout, +) !void { + var root = try Io.Dir.cwd().openDir(io, layout.agent_dir, .{}); + defer root.close(io); + + for (embedded_agent.files) |entry| { + // Ensure the file's parent directory exists. `path` is + // forward-slash separated (the generator normalizes that), + // so we can split off the dirname directly. + if (std.fs.path.dirname(entry.path)) |parent| { + root.createDirPath(io, parent) catch |err| switch (err) { + error.PathAlreadyExists => {}, + else => return err, + }; + } + try writeIfDifferent(allocator, io, root, entry.path, entry.contents); + } +} + /// Write `contents` into `dir/name` only if the existing file differs. /// Creates the file if absent. fn writeIfDifferent( diff --git a/src/main.zig b/src/main.zig index 3bc24e3..f95812d 100644 --- a/src/main.zig +++ b/src/main.zig @@ -399,14 +399,16 @@ pub fn main(init: std.process.Init) !void { // chance to register tools that might want to yield. try rt.installScheduler(); - // 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 - // between extensions abort startup. + // Discover Lua extensions across three layers — system + // ($PANTO_HOME/agent), user ($XDG_CONFIG_HOME/panto or + // $HOME/.config/panto), and project (./.panto). Project shadows + // user shadows system; tool-name collisions across surviving + // entries abort startup. const n_ext_tools = extension_loader.discoverAndLoad( alloc, io, init.environ_map, + luarocks_rt.layout.agent_dir, rt, ) catch |err| { std.log.err("extension discovery failed: {t}", .{err}); diff --git a/src/panto_home.zig b/src/panto_home.zig index b7c1799..353e54b 100644 --- a/src/panto_home.zig +++ b/src/panto_home.zig @@ -30,6 +30,11 @@ pub const Layout = struct { allocator: Allocator, /// `$PANTO_HOME` itself. home: []u8, + /// `$PANTO_HOME/agent/` — the "system" extension/tool tree, + /// populated at bootstrap from files embedded into the panto + /// binary. Searched after user/project layers for tools and + /// extensions; project shadows user shadows system. + agent_dir: []u8, /// `$PANTO_HOME/rocks/lua-/` — the versioned tree. tree: []u8, /// `/include/` — where Lua headers are staged. @@ -52,6 +57,7 @@ pub const Layout = struct { pub fn deinit(self: Layout) void { const a = self.allocator; a.free(self.home); + a.free(self.agent_dir); a.free(self.tree); a.free(self.include_dir); a.free(self.share_lua_dir); @@ -76,6 +82,9 @@ pub fn resolve( const home = try resolveHome(allocator, environ_map); errdefer allocator.free(home); + const agent_dir = try std.fs.path.join(allocator, &.{ home, "agent" }); + errdefer allocator.free(agent_dir); + // `/rocks/lua-` const tree_subdir = try std.fmt.allocPrint( allocator, @@ -111,6 +120,7 @@ pub fn resolve( return .{ .allocator = allocator, .home = home, + .agent_dir = agent_dir, .tree = tree, .include_dir = include_dir, .share_lua_dir = share_lua_dir, @@ -143,6 +153,7 @@ fn resolveHome( /// — existing directories are left alone. pub fn ensureDirsExist(layout: Layout, io: Io) !void { try makePathRecursive(io, layout.home); + try makePathRecursive(io, layout.agent_dir); try makePathRecursive(io, layout.tree); try makePathRecursive(io, layout.include_dir); try makePathRecursive(io, layout.share_lua_dir); -- cgit v1.3