summaryrefslogtreecommitdiff
path: root/libpanto/src/tool_registry.zig
diff options
context:
space:
mode:
authort <t@tjp.lol>2026-06-01 08:21:00 -0600
committert <t@tjp.lol>2026-06-01 11:20:56 -0600
commit1beefefc69beee214430eb5bd2528a4f5692d2a8 (patch)
treea459dbde5da17babe7d872999341d2006ebfe02e /libpanto/src/tool_registry.zig
parent5c016cfdbcb122e2b4f0496ef946642d68953c4b (diff)
Rename system extension layer to base for clarity
Replace all references to the "system" layer with "base" to better reflect its role as the foundational extension/tool layer in panto's hierarchy. The layer hierarchy now consistently uses: project > user > base. Includes: - Update agent README and build.zig documentation - Refactor tool registry and config module to support layered lookups - Add TestHarness abstraction for cleaner test setup - Improve JSON serialization with wire-encoded tool names - Add glob pattern matching for tool/extension discovery
Diffstat (limited to 'libpanto/src/tool_registry.zig')
-rw-r--r--libpanto/src/tool_registry.zig169
1 files changed, 168 insertions, 1 deletions
diff --git a/libpanto/src/tool_registry.zig b/libpanto/src/tool_registry.zig
index e8cb4d1..35aff02 100644
--- a/libpanto/src/tool_registry.zig
+++ b/libpanto/src/tool_registry.zig
@@ -26,6 +26,89 @@ const Tool = tool_mod.Tool;
const ToolSource = tool_source_mod.ToolSource;
const ToolDecl = tool_source_mod.ToolDecl;
+// ===========================================================================
+// Wire-name encoding
+// ===========================================================================
+//
+// Internally, tool names use dots for namespacing (`std.read`), which our
+// glob-based allow/deny policies rely on. But the OpenAI and Anthropic
+// tool-name grammars forbid dots: both require `^[a-zA-Z0-9_-]{1,128}$`.
+//
+// So names are translated at the wire boundary only: `.` <-> `__`. The
+// mapping is a clean bijection because a literal `__` is forbidden in an
+// internal name (enforced by `validateName` at registration). Everything
+// inside libpanto keeps speaking dots; only the serializers (via
+// `toolsForLLM`) and inbound dispatch (via `lookupLLM`) cross the boundary.
+
+/// The largest a wire (LLM-facing) tool name may be, per the provider
+/// grammars. We validate the *encoded* length against this so an encoded
+/// name is always acceptable to both providers.
+pub const max_wire_name_len = 128;
+
+pub const NameError = error{
+ /// Name is empty or its encoded form exceeds `max_wire_name_len`.
+ NameTooLong,
+ /// Name contains a literal `__` (reserved as the encoded form of `.`)
+ /// or a character outside `[a-zA-Z0-9_.-]`.
+ InvalidNameChar,
+};
+
+/// Validate an internal tool name. Permits `[a-zA-Z0-9_.-]` but forbids a
+/// literal `__` (which would collide with an encoded `.`), and requires
+/// the encoded form to be 1..=`max_wire_name_len` bytes. Each `.` expands
+/// to two bytes when encoded, so the cap is checked against that.
+pub fn validateName(name: []const u8) NameError!void {
+ if (name.len == 0) return error.NameTooLong;
+ var encoded_len: usize = 0;
+ for (name, 0..) |ch, i| {
+ const ok = (ch >= 'a' and ch <= 'z') or (ch >= 'A' and ch <= 'Z') or
+ (ch >= '0' and ch <= '9') or ch == '_' or ch == '-' or ch == '.';
+ if (!ok) return error.InvalidNameChar;
+ // Reject a literal double underscore: it is reserved for `.`.
+ if (ch == '_' and i + 1 < name.len and name[i + 1] == '_') return error.InvalidNameChar;
+ encoded_len += if (ch == '.') 2 else 1;
+ }
+ if (encoded_len > max_wire_name_len) return error.NameTooLong;
+}
+
+/// Encode an internal name for the wire: `.` -> `__`. Writes into `buf`
+/// (which must be at least `max_wire_name_len` bytes) and returns the
+/// written slice. Names that passed `validateName` always fit.
+pub fn encodeName(buf: []u8, name: []const u8) []const u8 {
+ var w: usize = 0;
+ for (name) |ch| {
+ if (ch == '.') {
+ buf[w] = '_';
+ buf[w + 1] = '_';
+ w += 2;
+ } else {
+ buf[w] = ch;
+ w += 1;
+ }
+ }
+ return buf[0..w];
+}
+
+/// Decode a wire name back to internal form: `__` -> `.`. Writes into
+/// `buf` (at least `wire.len` bytes) and returns the written slice. The
+/// decode is unambiguous because internal names never contain `__`.
+pub fn decodeName(buf: []u8, wire: []const u8) []u8 {
+ var r: usize = 0;
+ var w: usize = 0;
+ while (r < wire.len) {
+ if (wire[r] == '_' and r + 1 < wire.len and wire[r + 1] == '_') {
+ buf[w] = '.';
+ w += 1;
+ r += 2;
+ } else {
+ buf[w] = wire[r];
+ w += 1;
+ r += 1;
+ }
+ }
+ return buf[0..w];
+}
+
/// Tagged registry value. The registry stores one of these per *tool
/// name*. ToolSources expand to one entry per declared tool, each with a
/// distinct `tool_index`.
@@ -98,6 +181,7 @@ pub const ToolRegistry = struct {
/// In the duplicate case the caller's tool is NOT taken over; the
/// caller is responsible for tearing it down.
pub fn register(self: *ToolRegistry, tool: Tool) !void {
+ try validateName(tool.decl.name);
const gop = try self.entries.getOrPut(tool.decl.name);
if (gop.found_existing) return error.DuplicateTool;
gop.value_ptr.* = .{ .single = tool };
@@ -113,8 +197,10 @@ pub const ToolRegistry = struct {
/// down) and any tools that *had* been inserted before the collision
/// are rolled back.
pub fn registerSource(self: *ToolRegistry, src: ToolSource) !void {
- // First pass: check for any collision before committing.
+ // First pass: validate names and check for any collision before
+ // committing anything.
for (src.tools) |decl| {
+ try validateName(decl.name);
if (self.entries.contains(decl.name)) return error.DuplicateTool;
}
@@ -183,6 +269,27 @@ pub const ToolRegistry = struct {
}
};
+ /// Iterate tools with their names **wire-encoded** (`.` -> `__`) for
+ /// the LLM. The yielded `ToolView.decl.name` borrows the iterator's
+ /// internal buffer and is only valid until the next `next()` call;
+ /// serializers consume it immediately, so this is safe. Description
+ /// and schema are unchanged.
+ pub fn toolsForLLM(self: *const ToolRegistry) LLMIterator {
+ return .{ .inner = self.entries.iterator() };
+ }
+
+ pub const LLMIterator = struct {
+ inner: std.StringHashMap(Entry).Iterator,
+ name_buf: [max_wire_name_len]u8 = undefined,
+
+ pub fn next(self: *LLMIterator) ?ToolView {
+ const entry = self.inner.next() orelse return null;
+ var view = makeView(entry.value_ptr.*);
+ view.decl.name = encodeName(&self.name_buf, view.decl.name);
+ return view;
+ }
+ };
+
fn makeView(entry: Entry) ToolView {
return switch (entry) {
.single => |t| .{ .decl = t.decl, .entry = entry },
@@ -489,3 +596,63 @@ test "source view exposes per-tool metadata uniformly" {
}
try testing.expectEqual(@as(usize, 3), n);
}
+
+// --- wire-name encoding ---
+
+test "validateName: accepts dotted names, rejects literal __ and bad chars" {
+ try validateName("std.read");
+ try validateName("pkg.read_file");
+ try validateName("a-b_c.d");
+ try testing.expectError(error.InvalidNameChar, validateName("std__read"));
+ try testing.expectError(error.InvalidNameChar, validateName("has space"));
+ try testing.expectError(error.InvalidNameChar, validateName("slash/name"));
+ try testing.expectError(error.NameTooLong, validateName(""));
+ // 64 dots -> 128 encoded bytes: OK; 65 -> 130: too long.
+ try validateName("." ** 64);
+ try testing.expectError(error.NameTooLong, validateName("." ** 65));
+}
+
+test "encode/decode: dots <-> double underscores, bijective" {
+ var buf: [max_wire_name_len]u8 = undefined;
+ var buf2: [max_wire_name_len]u8 = undefined;
+
+ const cases = [_][]const u8{ "std.read", "pkg.read_file", "a.b.c", "plain", "a-b" };
+ inline for (cases) |internal| {
+ const wire = encodeName(&buf, internal);
+ try testing.expect(std.mem.indexOf(u8, wire, ".") == null);
+ const back = decodeName(&buf2, wire);
+ try testing.expectEqualStrings(internal, back);
+ }
+
+ // Spot-check the exact wire form and the read_file distinction.
+ try testing.expectEqualStrings("std__read", encodeName(&buf, "std.read"));
+ try testing.expectEqualStrings("pkg__read_file", encodeName(&buf, "pkg.read_file"));
+ try testing.expectEqualStrings("pkg__read__file", encodeName(&buf, "pkg.read.file"));
+}
+
+test "register rejects names with literal double underscore" {
+ const allocator = testing.allocator;
+ var reg = ToolRegistry.init(allocator);
+ defer reg.deinit();
+
+ var bad = try TestTool.create(allocator, "std__read");
+ try testing.expectError(error.InvalidNameChar, reg.register(bad));
+ // Registration refused ownership; tear the tool down ourselves.
+ bad.vtable.deinit(bad.ctx, allocator);
+}
+
+test "toolsForLLM yields wire-encoded names; iterator keeps dotted names" {
+ const allocator = testing.allocator;
+ var reg = ToolRegistry.init(allocator);
+ defer reg.deinit();
+ try reg.register(try TestTool.create(allocator, "std.read"));
+
+ var llm = reg.toolsForLLM();
+ const v = llm.next().?;
+ try testing.expectEqualStrings("std__read", v.decl.name);
+ try testing.expect(llm.next() == null);
+
+ // The internal iterator is unchanged.
+ var it = reg.iterator();
+ try testing.expectEqualStrings("std.read", it.next().?.decl.name);
+}