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 ++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 156 insertions(+), 9 deletions(-) (limited to 'src/extension_loader.zig') 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 -- cgit v1.3