diff options
Diffstat (limited to 'libpanto/src/tool_registry.zig')
| -rw-r--r-- | libpanto/src/tool_registry.zig | 169 |
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); +} |
