From 1beefefc69beee214430eb5bd2528a4f5692d2a8 Mon Sep 17 00:00:00 2001 From: t Date: Mon, 1 Jun 2026 08:21:00 -0600 Subject: 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 --- libpanto/src/agent.zig | 383 +++++++++++++++++---------- libpanto/src/anthropic_messages_json.zig | 47 +++- libpanto/src/config.zig | 118 ++++++++- libpanto/src/openai_chat_json.zig | 36 ++- libpanto/src/provider.zig | 109 ++++---- libpanto/src/provider_anthropic_messages.zig | 113 ++++---- libpanto/src/provider_openai_chat.zig | 114 ++++---- libpanto/src/root.zig | 12 +- libpanto/src/tool_registry.zig | 169 +++++++++++- 9 files changed, 777 insertions(+), 324 deletions(-) (limited to 'libpanto/src') diff --git a/libpanto/src/agent.zig b/libpanto/src/agent.zig index 92c5a3a..08cc91c 100644 --- a/libpanto/src/agent.zig +++ b/libpanto/src/agent.zig @@ -29,6 +29,7 @@ const Allocator = std.mem.Allocator; const Io = std.Io; const provider_mod = @import("provider.zig"); +const config_mod = @import("config.zig"); const conversation = @import("conversation.zig"); const tool_mod = @import("tool.zig"); const tool_source_mod = @import("tool_source.zig"); @@ -40,40 +41,46 @@ pub const ToolRegistry = tool_registry_mod.ToolRegistry; const Entry = tool_registry_mod.Entry; +pub const Config = config_mod.Config; + pub const Agent = struct { - provider: provider_mod.Provider, allocator: Allocator, io: Io, - registry: ToolRegistry, - - pub fn init(allocator: Allocator, io: Io, prov: provider_mod.Provider) Agent { + /// The active configuration snapshot, consulted fresh at the top of + /// every turn. Immutable while a turn is in flight; swap this pointer + /// (`setConfig`) between turns to change provider/model/base_url and/or + /// the visible tool set atomically. The pointee and its registry are + /// owned by the embedder, not the agent. + config: *const Config, + /// Injectable streaming seam. Defaults to the real provider dispatch + /// (`provider_mod.streamStep`); tests override it with a stub. + stream_fn: provider_mod.StreamFn = provider_mod.streamStep, + + pub fn init(allocator: Allocator, io: Io, config: *const Config) Agent { return .{ - .provider = prov, .allocator = allocator, .io = io, - .registry = ToolRegistry.init(allocator), + .config = config, }; } pub fn deinit(self: *Agent) void { - self.registry.deinit(); - self.provider.deinit(); + // The agent owns neither the config snapshot nor the registry it + // borrows; the embedder tears those down. + _ = self; } - /// Register a single tool. The agent's registry takes ownership. - pub fn registerTool(self: *Agent, tool: Tool) !void { - try self.registry.register(tool); + /// Swap the active configuration snapshot. Takes effect at the start of + /// the next turn. Safe to call between `runStep` invocations or from a + /// tool handler that runs between provider steps; never mutates a + /// snapshot a turn is currently reading. + pub fn setConfig(self: *Agent, config: *const Config) void { + self.config = config; } - /// Register a tool source. The agent's registry takes ownership. - pub fn registerToolSource(self: *Agent, src: ToolSource) !void { - try self.registry.registerSource(src); - } - - /// Remove a tool by name. No-op if not registered or if the name - /// belongs to a source. - pub fn unregisterTool(self: *Agent, name: []const u8) void { - self.registry.unregister(name); + /// The registry exposed by the active snapshot. + pub fn registry(self: *const Agent) *const ToolRegistry { + return self.config.registry; } /// Drive the conversation forward until the model stops calling tools. @@ -83,7 +90,10 @@ pub const Agent = struct { receiver: *provider_mod.Receiver, ) !void { while (true) { - try self.provider.streamStep(conv, &self.registry, receiver); + // Re-read the config snapshot at the top of each turn so a + // mid-conversation swap takes effect here, never mid-stream. + const cfg = self.config; + try self.stream_fn(self.allocator, self.io, cfg, conv, receiver); const last = conv.messages.items[conv.messages.items.len - 1]; std.debug.assert(last.role == .assistant); @@ -122,7 +132,7 @@ pub const Agent = struct { for (assistant_msg.content.items) |block| { if (block != .ToolUse) continue; const tu = block.ToolUse; - const entry = self.registry.lookup(tu.name) orelse { + const entry = self.config.registry.lookup(tu.name) orelse { // Unknown tool: abort the turn with a clear error. return error.UnknownTool; }; @@ -379,6 +389,15 @@ fn runSourceGroup(agent: *Agent, sg: Group.SourceGroup, calls: []FlatCall) void const testing = std.testing; +/// Test harness for the injectable `stream_fn` seam. +/// +/// `provider_mod.StreamFn` carries no user context (it mirrors the real +/// free function exactly), so the stub parks its state in a module-level +/// pointer that `stubStreamStep` reads. The Zig test runner executes tests +/// serially in one process, so a single global slot is safe; each test +/// sets it via `install` before driving the agent. +var stub_active: ?*StubProvider = null; + const StubProvider = struct { allocator: Allocator, scripted: []const ScriptedTurn, @@ -397,60 +416,85 @@ const StubProvider = struct { }, }; - fn provider(self: *StubProvider) provider_mod.Provider { - return .{ .ptr = self, .vtable = &vt }; + /// Point the global seam at this stub and return the function to assign + /// to `agent.stream_fn`. Call once per test, after constructing the + /// stub on the stack. + fn install(self: *StubProvider) provider_mod.StreamFn { + stub_active = self; + return stubStreamStep; } +}; - const vt: provider_mod.ProviderVTable = .{ - .streamStep = vtStreamStep, - .deinit = vtDeinit, - }; +fn stubStreamStep( + allocator: Allocator, + _: Io, + _: *const config_mod.Config, + conv: *conversation.Conversation, + _: *provider_mod.Receiver, +) anyerror!void { + const self = stub_active orelse return error.NoStubInstalled; + _ = allocator; + if (self.next >= self.scripted.len) return error.NoMoreScriptedTurns; + const turn = self.scripted[self.next]; + self.next += 1; + + var blocks: std.ArrayList(conversation.ContentBlock) = .empty; + errdefer { + for (blocks.items) |*b| b.deinit(self.allocator); + blocks.deinit(self.allocator); + } + for (turn.blocks) |tb| { + switch (tb) { + .Text => |s| { + try blocks.append(self.allocator, .{ + .Text = try conversation.textualBlockFromSlice(self.allocator, s), + }); + }, + .ToolUse => |tu| { + const id = try self.allocator.dupe(u8, tu.id); + errdefer self.allocator.free(id); + const name = try self.allocator.dupe(u8, tu.name); + errdefer self.allocator.free(name); + var input_buf: conversation.TextualBlock = .empty; + errdefer input_buf.deinit(self.allocator); + try input_buf.appendSlice(self.allocator, tu.input); + try blocks.append(self.allocator, .{ .ToolUse = .{ + .id = id, + .name = name, + .input = input_buf, + } }); + }, + } + } + const moved = try blocks.toOwnedSlice(self.allocator); + defer self.allocator.free(moved); + try conv.addAssistantMessage(moved); +} - fn vtStreamStep( - ptr: *anyopaque, - conv: *conversation.Conversation, - _: *const ToolRegistry, - _: *provider_mod.Receiver, - ) anyerror!void { - const self: *StubProvider = @ptrCast(@alignCast(ptr)); - if (self.next >= self.scripted.len) return error.NoMoreScriptedTurns; - const turn = self.scripted[self.next]; - self.next += 1; +/// Build a stack registry + active `Config` snapshot wired together, for +/// tests that drive the agent. The caller owns both and must keep them +/// alive for the agent's lifetime. +const TestHarness = struct { + registry: ToolRegistry, + config: config_mod.Config, - var blocks: std.ArrayList(conversation.ContentBlock) = .empty; - errdefer { - for (blocks.items) |*b| b.deinit(self.allocator); - blocks.deinit(self.allocator); - } - for (turn.blocks) |tb| { - switch (tb) { - .Text => |s| { - try blocks.append(self.allocator, .{ - .Text = try conversation.textualBlockFromSlice(self.allocator, s), - }); - }, - .ToolUse => |tu| { - const id = try self.allocator.dupe(u8, tu.id); - errdefer self.allocator.free(id); - const name = try self.allocator.dupe(u8, tu.name); - errdefer self.allocator.free(name); - var input_buf: conversation.TextualBlock = .empty; - errdefer input_buf.deinit(self.allocator); - try input_buf.appendSlice(self.allocator, tu.input); - try blocks.append(self.allocator, .{ .ToolUse = .{ - .id = id, - .name = name, - .input = input_buf, - } }); - }, - } - } - const moved = try blocks.toOwnedSlice(self.allocator); - defer self.allocator.free(moved); - try conv.addAssistantMessage(moved); + fn init(allocator: Allocator) TestHarness { + return .{ .registry = ToolRegistry.init(allocator), .config = undefined }; } - fn vtDeinit(_: *anyopaque) void {} + /// Finalize the config snapshot to point at this harness's registry. + /// Must be called after `init` and before constructing the agent, once + /// the harness has a stable address. + fn activate(self: *TestHarness) void { + self.config = .{ + .provider = .{ .openai_chat = .{ .api_key = "k", .base_url = "u", .model = "m" } }, + .registry = &self.registry, + }; + } + + fn deinit(self: *TestHarness) void { + self.registry.deinit(); + } }; const EchoTool = struct { @@ -574,7 +618,7 @@ const FailingTool = struct { const NoopReceiver = struct { fn make() provider_mod.Receiver { - return .{ .ptr = @constCast(@ptrCast(&dummy)), .vtable = &vt }; + return .{ .ptr = @ptrCast(@constCast(&dummy)), .vtable = &vt }; } var dummy: u8 = 0; const vt: provider_mod.ReceiverVTable = .{ @@ -772,31 +816,21 @@ const FailingSource = struct { } }; -test "registerTool and lookup via registry" { - var stub = StubProvider{ .allocator = testing.allocator, .scripted = &.{} }; - var threaded: std.Io.Threaded = .init(testing.allocator, .{}); - defer threaded.deinit(); - const io = threaded.io(); - var agent = Agent.init(testing.allocator, io, stub.provider()); - defer agent.deinit(); - - try agent.registerTool(try EchoTool.create(testing.allocator, "echo", "ECHO:")); - try testing.expectEqual(@as(usize, 1), agent.registry.count()); - try testing.expect(agent.registry.lookup("echo") != null); +test "registry register and lookup" { + var h = TestHarness.init(testing.allocator); + defer h.deinit(); + try h.registry.register(try EchoTool.create(testing.allocator, "echo", "ECHO:")); + try testing.expectEqual(@as(usize, 1), h.registry.count()); + try testing.expect(h.registry.lookup("echo") != null); } -test "duplicate registerTool returns error" { - var stub = StubProvider{ .allocator = testing.allocator, .scripted = &.{} }; - var threaded: std.Io.Threaded = .init(testing.allocator, .{}); - defer threaded.deinit(); - const io = threaded.io(); - var agent = Agent.init(testing.allocator, io, stub.provider()); - defer agent.deinit(); - - try agent.registerTool(try EchoTool.create(testing.allocator, "echo", "A:")); +test "duplicate register returns error" { + var h = TestHarness.init(testing.allocator); + defer h.deinit(); + try h.registry.register(try EchoTool.create(testing.allocator, "echo", "A:")); var dup = try EchoTool.create(testing.allocator, "echo", "B:"); - try testing.expectError(error.DuplicateTool, agent.registerTool(dup)); + try testing.expectError(error.DuplicateTool, h.registry.register(dup)); dup.vtable.deinit(dup.ctx, testing.allocator); } @@ -815,10 +849,12 @@ test "runStep dispatches a tool call and loops to a final text turn" { var threaded: std.Io.Threaded = .init(allocator, .{}); defer threaded.deinit(); const io = threaded.io(); - var agent = Agent.init(allocator, io, stub.provider()); - defer agent.deinit(); - - try agent.registerTool(try EchoTool.create(allocator, "echo", "ECHO:")); + var h = TestHarness.init(allocator); + defer h.deinit(); + try h.registry.register(try EchoTool.create(allocator, "echo", "ECHO:")); + h.activate(); + var agent = Agent.init(allocator, io, &h.config); + agent.stream_fn = stub.install(); var conv = conversation.Conversation.init(allocator); defer conv.deinit(); @@ -862,12 +898,14 @@ test "runStep dispatches multiple tool calls in parallel" { var threaded: std.Io.Threaded = .init(allocator, .{}); defer threaded.deinit(); const io = threaded.io(); - var agent = Agent.init(allocator, io, stub.provider()); - defer agent.deinit(); - - try agent.registerTool(try BarrierTool.create(allocator, "barrierA", &barrier)); - try agent.registerTool(try BarrierTool.create(allocator, "barrierB", &barrier)); - try agent.registerTool(try BarrierTool.create(allocator, "barrierC", &barrier)); + var h = TestHarness.init(allocator); + defer h.deinit(); + try h.registry.register(try BarrierTool.create(allocator, "barrierA", &barrier)); + try h.registry.register(try BarrierTool.create(allocator, "barrierB", &barrier)); + try h.registry.register(try BarrierTool.create(allocator, "barrierC", &barrier)); + h.activate(); + var agent = Agent.init(allocator, io, &h.config); + agent.stream_fn = stub.install(); var conv = conversation.Conversation.init(allocator); defer conv.deinit(); @@ -902,10 +940,12 @@ test "runStep propagates tool errors and aborts the turn" { var threaded: std.Io.Threaded = .init(allocator, .{}); defer threaded.deinit(); const io = threaded.io(); - var agent = Agent.init(allocator, io, stub.provider()); - defer agent.deinit(); - - try agent.registerTool(try FailingTool.create(allocator, "boom")); + var h = TestHarness.init(allocator); + defer h.deinit(); + try h.registry.register(try FailingTool.create(allocator, "boom")); + h.activate(); + var agent = Agent.init(allocator, io, &h.config); + agent.stream_fn = stub.install(); var conv = conversation.Conversation.init(allocator); defer conv.deinit(); @@ -929,8 +969,11 @@ test "runStep errors UnknownTool when the model calls something unregistered" { var threaded: std.Io.Threaded = .init(allocator, .{}); defer threaded.deinit(); const io = threaded.io(); - var agent = Agent.init(allocator, io, stub.provider()); - defer agent.deinit(); + var h = TestHarness.init(allocator); + defer h.deinit(); + h.activate(); + var agent = Agent.init(allocator, io, &h.config); + agent.stream_fn = stub.install(); var conv = conversation.Conversation.init(allocator); defer conv.deinit(); @@ -950,8 +993,11 @@ test "runStep with no tool calls returns after one provider step" { var threaded: std.Io.Threaded = .init(allocator, .{}); defer threaded.deinit(); const io = threaded.io(); - var agent = Agent.init(allocator, io, stub.provider()); - defer agent.deinit(); + var h = TestHarness.init(allocator); + defer h.deinit(); + h.activate(); + var agent = Agent.init(allocator, io, &h.config); + agent.stream_fn = stub.install(); var conv = conversation.Conversation.init(allocator); defer conv.deinit(); @@ -974,8 +1020,11 @@ test "runStep surfaces EmptyAssistantResponse when provider commits an empty mes var threaded: std.Io.Threaded = .init(allocator, .{}); defer threaded.deinit(); const io = threaded.io(); - var agent = Agent.init(allocator, io, stub.provider()); - defer agent.deinit(); + var h = TestHarness.init(allocator); + defer h.deinit(); + h.activate(); + var agent = Agent.init(allocator, io, &h.config); + agent.stream_fn = stub.install(); var conv = conversation.Conversation.init(allocator); defer conv.deinit(); @@ -1002,10 +1051,12 @@ test "runStep delivers all source-backed calls in one batch on one thread" { var threaded: std.Io.Threaded = .init(allocator, .{}); defer threaded.deinit(); const io = threaded.io(); - var agent = Agent.init(allocator, io, stub.provider()); - defer agent.deinit(); - - try agent.registerToolSource(try TestSource.create(allocator, "panto-lua", &.{ "lua_x", "lua_y" })); + var h = TestHarness.init(allocator); + defer h.deinit(); + try h.registry.registerSource(try TestSource.create(allocator, "panto-lua", &.{ "lua_x", "lua_y" })); + h.activate(); + var agent = Agent.init(allocator, io, &h.config); + agent.stream_fn = stub.install(); var conv = conversation.Conversation.init(allocator); defer conv.deinit(); @@ -1015,7 +1066,7 @@ test "runStep delivers all source-backed calls in one batch on one thread" { try agent.runStep(&conv, &recv); // Locate the source and inspect its observed batches. - const view = agent.registry.lookup("lua_x") orelse return error.NotFound; + const view = h.registry.lookup("lua_x") orelse return error.NotFound; const src_ptr = view.entry.source.source; const test_src: *TestSource = @ptrCast(@alignCast(src_ptr.ctx)); @@ -1051,11 +1102,13 @@ test "runStep: distinct sources run on distinct threads in parallel" { var threaded: std.Io.Threaded = .init(allocator, .{}); defer threaded.deinit(); const io = threaded.io(); - var agent = Agent.init(allocator, io, stub.provider()); - defer agent.deinit(); - - try agent.registerToolSource(try TestSource.create(allocator, "src_a", &.{"src_a_t"})); - try agent.registerToolSource(try TestSource.create(allocator, "src_b", &.{"src_b_t"})); + var h = TestHarness.init(allocator); + defer h.deinit(); + try h.registry.registerSource(try TestSource.create(allocator, "src_a", &.{"src_a_t"})); + try h.registry.registerSource(try TestSource.create(allocator, "src_b", &.{"src_b_t"})); + h.activate(); + var agent = Agent.init(allocator, io, &h.config); + agent.stream_fn = stub.install(); var conv = conversation.Conversation.init(allocator); defer conv.deinit(); @@ -1064,8 +1117,8 @@ test "runStep: distinct sources run on distinct threads in parallel" { var recv = NoopReceiver.make(); try agent.runStep(&conv, &recv); - const view_a = agent.registry.lookup("src_a_t") orelse return error.NotFound; - const view_b = agent.registry.lookup("src_b_t") orelse return error.NotFound; + const view_a = h.registry.lookup("src_a_t") orelse return error.NotFound; + const view_b = h.registry.lookup("src_b_t") orelse return error.NotFound; const sa: *TestSource = @ptrCast(@alignCast(view_a.entry.source.source.ctx)); const sb: *TestSource = @ptrCast(@alignCast(view_b.entry.source.source.ctx)); @@ -1089,10 +1142,12 @@ test "runStep: source whole-batch error aborts the turn" { var threaded: std.Io.Threaded = .init(allocator, .{}); defer threaded.deinit(); const io = threaded.io(); - var agent = Agent.init(allocator, io, stub.provider()); - defer agent.deinit(); - - try agent.registerToolSource(try FailingSource.create(allocator, "fs", &.{ "fa", "fb" })); + var h = TestHarness.init(allocator); + defer h.deinit(); + try h.registry.registerSource(try FailingSource.create(allocator, "fs", &.{ "fa", "fb" })); + h.activate(); + var agent = Agent.init(allocator, io, &h.config); + agent.stream_fn = stub.install(); var conv = conversation.Conversation.init(allocator); defer conv.deinit(); @@ -1120,11 +1175,13 @@ test "runStep: mixed single Tools and source-backed tools coexist in one turn" { var threaded: std.Io.Threaded = .init(allocator, .{}); defer threaded.deinit(); const io = threaded.io(); - var agent = Agent.init(allocator, io, stub.provider()); - defer agent.deinit(); - - try agent.registerTool(try EchoTool.create(allocator, "single", "S:")); - try agent.registerToolSource(try TestSource.create(allocator, "src", &.{ "src_t1", "src_t2" })); + var h = TestHarness.init(allocator); + defer h.deinit(); + try h.registry.register(try EchoTool.create(allocator, "single", "S:")); + try h.registry.registerSource(try TestSource.create(allocator, "src", &.{ "src_t1", "src_t2" })); + h.activate(); + var agent = Agent.init(allocator, io, &h.config); + agent.stream_fn = stub.install(); var conv = conversation.Conversation.init(allocator); defer conv.deinit(); @@ -1139,3 +1196,63 @@ test "runStep: mixed single Tools and source-backed tools coexist in one turn" { try testing.expectEqualStrings("src_t1->Y", tr_msg.content.items[1].ToolResult.content.items); try testing.expectEqualStrings("src_t2->Z", tr_msg.content.items[2].ToolResult.content.items); } + +test "setConfig swaps the visible tool set between turns" { + // The core RCU promise: the agent reads `*const Config` fresh each + // turn, so swapping the pointer mid-conversation changes the tool set + // the next turn sees. Config A exposes only `echo`; config B only + // `late`. After `setConfig(&cfg_b)`, a turn that calls `late` resolves + // — proving both the swap and per-turn re-consultation. + const allocator = testing.allocator; + + const scripted = [_]StubProvider.ScriptedTurn{ + .{ .blocks = &.{.{ .ToolUse = .{ .id = "2", .name = "late", .input = "B" } }} }, + .{ .blocks = &.{.{ .Text = "done" }} }, + }; + var stub = StubProvider{ .allocator = allocator, .scripted = &scripted }; + var threaded: std.Io.Threaded = .init(allocator, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + // Config A: only `echo`. + var reg_a = ToolRegistry.init(allocator); + defer reg_a.deinit(); + try reg_a.register(try EchoTool.create(allocator, "echo", "A:")); + const cfg_a: config_mod.Config = .{ + .provider = .{ .openai_chat = .{ .api_key = "k", .base_url = "u", .model = "m" } }, + .registry = ®_a, + }; + + // Config B: only `late`. + var reg_b = ToolRegistry.init(allocator); + defer reg_b.deinit(); + try reg_b.register(try EchoTool.create(allocator, "late", "B:")); + const cfg_b: config_mod.Config = .{ + .provider = .{ .openai_chat = .{ .api_key = "k", .base_url = "u", .model = "m" } }, + .registry = ®_b, + }; + + var agent = Agent.init(allocator, io, &cfg_a); + agent.stream_fn = stub.install(); + + // Under A: `echo` visible, `late` not. + try testing.expect(agent.config.registry.lookup("echo") != null); + try testing.expect(agent.config.registry.lookup("late") == null); + + // Swap. Under B: the visibility inverts. + agent.setConfig(&cfg_b); + try testing.expect(agent.config.registry.lookup("echo") == null); + try testing.expect(agent.config.registry.lookup("late") != null); + + // A real turn under B resolves `late` (which would have been + // UnknownTool under A), then loops to the final text turn. + var conv = conversation.Conversation.init(allocator); + defer conv.deinit(); + try conv.addUserMessage("go"); + var recv = NoopReceiver.make(); + try agent.runStep(&conv, &recv); + + const tr = conv.messages.items[2].content.items[0].ToolResult; + try testing.expectEqualStrings("2", tr.tool_use_id); + try testing.expectEqualStrings("B:B", tr.content.items); +} diff --git a/libpanto/src/anthropic_messages_json.zig b/libpanto/src/anthropic_messages_json.zig index 46ec17c..5b65c2b 100644 --- a/libpanto/src/anthropic_messages_json.zig +++ b/libpanto/src/anthropic_messages_json.zig @@ -64,10 +64,11 @@ pub fn serializeRequest( if (tools.count() > 0) { try s.objectField("tools"); try s.beginArray(); - var it = tools.iterator(); + var it = tools.toolsForLLM(); while (it.next()) |t| { try s.beginObject(); try s.objectField("name"); + // `t.decl.name` is already wire-encoded by `toolsForLLM`. try s.write(t.decl.name); try s.objectField("description"); try s.write(t.decl.description); @@ -180,7 +181,9 @@ fn writeBlock(s: *std.json.Stringify, block: conversation.ContentBlock) !void { try s.objectField("id"); try s.write(tu.id); try s.objectField("name"); - try s.write(tu.name); + // Replayed assistant tool_use: encode the stored dotted name. + var name_buf: [tool_registry_mod.max_wire_name_len]u8 = undefined; + try s.write(tool_registry_mod.encodeName(&name_buf, tu.name)); try s.objectField("input"); // Anthropic expects `input` as a nested JSON object, not a // string. The block's input bytes were assembled from @@ -801,9 +804,7 @@ test "serializeRequest - emits tools array when registry non-empty" { var tools = tool_registry_mod.ToolRegistry.init(allocator); defer tools.deinit(); - try tools.register(makeStaticTool( - "echo", - "Echo a message back.", + try tools.register(makeStaticTool("echo", "Echo a message back.", \\{"type":"object","properties":{"message":{"type":"string"}},"required":["message"]} )); @@ -824,6 +825,42 @@ test "serializeRequest - emits tools array when registry non-empty" { try testing.expect(schema.get("properties").? == .object); } +test "serializeRequest - dotted names are wire-encoded in tools and history" { + const allocator = testing.allocator; + + var conv = conversation.Conversation.init(allocator); + defer conv.deinit(); + // A prior assistant tool_use replayed from history (dotted internally). + const id = try allocator.dupe(u8, "tu_1"); + const name = try allocator.dupe(u8, "std.write"); + var input: conversation.TextualBlock = .empty; + try input.appendSlice(allocator, "{}"); + try conv.addAssistantMessage(&.{ + .{ .ToolUse = .{ .id = id, .name = name, .input = input } }, + }); + + var tools = tool_registry_mod.ToolRegistry.init(allocator); + defer tools.deinit(); + try tools.register(makeStaticTool("std.read", "Read.", "{\"type\":\"object\"}")); + + const cfg = testConfig("claude-x"); + const body = try serializeRequest(allocator, &cfg, &conv, &tools); + defer allocator.free(body); + + var parsed = try std.json.parseFromSlice(std.json.Value, allocator, body, .{}); + defer parsed.deinit(); + + // Tool list: `std.read` -> `std__read`. + const tool_name = parsed.value.object.get("tools").?.array.items[0] + .object.get("name").?.string; + try testing.expectEqualStrings("std__read", tool_name); + + // Replayed history tool_use: `std.write` -> `std__write`. + const hist_name = parsed.value.object.get("messages").?.array.items[0] + .object.get("content").?.array.items[0].object.get("name").?.string; + try testing.expectEqualStrings("std__write", hist_name); +} + test "serializeRequest - assistant ToolUse becomes tool_use content block" { const allocator = testing.allocator; diff --git a/libpanto/src/config.zig b/libpanto/src/config.zig index e395230..6744b56 100644 --- a/libpanto/src/config.zig +++ b/libpanto/src/config.zig @@ -1,9 +1,24 @@ -//! Per-provider configuration. +//! Active configuration the agent consults on every turn. //! -//! `Config` is a tagged union keyed by `APIStyle`. Each variant carries the -//! settings specific to one wire dialect. New providers add a new tag and a -//! new payload struct here; nothing else in libpanto needs a central enum -//! refresh. +//! `ProviderConfig` is a tagged union keyed by `APIStyle`; each variant +//! carries the settings specific to one wire dialect. New providers add a +//! new tag and a new payload struct here; nothing else in libpanto needs a +//! central enum refresh. +//! +//! `Config` bundles the active `ProviderConfig` together with the +//! `ToolRegistry` the agent should expose this turn. It is an **immutable +//! snapshot**: the agent holds a `*const Config` and re-reads it at the top +//! of every turn, so swapping that pointer (e.g. from a `panto.configure` +//! hook) changes provider, model, base_url, and/or the visible tool set +//! atomically at the next turn boundary. Because the snapshot is read-only +//! while a turn is in flight, concurrent tool workers reading the old +//! snapshot stay consistent. + +const std = @import("std"); +const Io = std.Io; +const tool_registry_mod = @import("tool_registry.zig"); + +pub const ToolRegistry = tool_registry_mod.ToolRegistry; /// The wire dialect a provider speaks. pub const APIStyle = enum { @@ -43,19 +58,74 @@ pub const AnthropicMessagesConfig = struct { max_tokens: u32 = 4096, }; -pub const Config = union(APIStyle) { +/// Per-provider transport/auth/model configuration. Tagged by `APIStyle`. +pub const ProviderConfig = union(APIStyle) { openai_chat: OpenAIChatConfig, anthropic_messages: AnthropicMessagesConfig, - pub fn style(self: Config) APIStyle { + pub fn style(self: ProviderConfig) APIStyle { return @as(APIStyle, self); } }; -const t = @import("std").testing; +/// An immutable snapshot of everything the agent consults per turn: which +/// provider/model to talk to, and which tools to expose. The agent holds a +/// `*const Config` and re-reads it each turn; replacing the pointer swaps +/// the active configuration wholesale at the next turn boundary. +/// +/// `registry` is borrowed, not owned — its lifetime is managed by whoever +/// built the snapshot (typically the embedder). A `Config` may be copied +/// freely; copies share the same borrowed registry. +pub const Config = struct { + provider: ProviderConfig, + registry: *const ToolRegistry, + + pub fn style(self: Config) APIStyle { + return self.provider.style(); + } +}; + +// =========================================================================== +// Process-global HTTP client +// =========================================================================== +// +// `std.http.Client`'s connection pool is mutex-guarded and keyed by host, +// so a single client safely multiplexes every provider/base_url the agent +// ever switches to, across concurrent turns. We keep exactly one for the +// whole process: switching `base_url` simply leaves the old host's idle +// connections to time out (and reuses them if the user switches back). +// +// Embedders must call `initHttp` once before any turn and `deinitHttp` +// once at shutdown. + +var global_http: ?std.http.Client = null; + +/// Initialize the process-global HTTP client. Call once from the embedder's +/// `main()` before driving any agent turns. Idempotent: a second call with +/// an already-initialized client is a no-op. +pub fn initHttp(allocator: std.mem.Allocator, io: Io) void { + if (global_http != null) return; + global_http = .{ .allocator = allocator, .io = io }; +} + +/// Tear down the process-global HTTP client. Call once at shutdown, after +/// all turns have completed. +pub fn deinitHttp() void { + if (global_http) |*c| { + c.deinit(); + global_http = null; + } +} + +/// Borrow the process-global HTTP client. Asserts `initHttp` has run. +pub fn httpClient() *std.http.Client { + return &(global_http orelse @panic("libpanto: httpClient() called before initHttp()")); +} -test "Config - openai_chat variant" { - const cfg: Config = .{ .openai_chat = .{ +const t = std.testing; + +test "ProviderConfig - openai_chat variant" { + const cfg: ProviderConfig = .{ .openai_chat = .{ .api_key = "sk-test", .base_url = "https://api.openai.com/v1", .model = "gpt-4o", @@ -66,8 +136,8 @@ test "Config - openai_chat variant" { try t.expectEqual(ReasoningEffort.high, cfg.openai_chat.reasoning); } -test "Config - anthropic_messages variant" { - const cfg: Config = .{ .anthropic_messages = .{ +test "ProviderConfig - anthropic_messages variant" { + const cfg: ProviderConfig = .{ .anthropic_messages = .{ .api_key = "sk-ant-test", .base_url = "https://api.anthropic.com", .model = "claude-sonnet-4-20250514", @@ -77,11 +147,31 @@ test "Config - anthropic_messages variant" { try t.expectEqual(@as(u32, 4096), cfg.anthropic_messages.max_tokens); } -test "Config - openai_chat reasoning defaults to .default" { - const cfg: Config = .{ .openai_chat = .{ +test "ProviderConfig - openai_chat reasoning defaults to .default" { + const cfg: ProviderConfig = .{ .openai_chat = .{ .api_key = "k", .base_url = "u", .model = "m", } }; try t.expectEqual(ReasoningEffort.default, cfg.openai_chat.reasoning); } + +test "Config bundles provider + registry and forwards style" { + var reg = ToolRegistry.init(t.allocator); + defer reg.deinit(); + const cfg: Config = .{ + .provider = .{ .openai_chat = .{ .api_key = "k", .base_url = "u", .model = "m" } }, + .registry = ®, + }; + try t.expectEqual(APIStyle.openai_chat, cfg.style()); +} + +test "global http client: init/borrow/deinit" { + var threaded: std.Io.Threaded = .init(t.allocator, .{}); + defer threaded.deinit(); + const io = threaded.io(); + initHttp(t.allocator, io); + defer deinitHttp(); + initHttp(t.allocator, io); // idempotent + _ = httpClient(); +} diff --git a/libpanto/src/openai_chat_json.zig b/libpanto/src/openai_chat_json.zig index 37d347b..6991559 100644 --- a/libpanto/src/openai_chat_json.zig +++ b/libpanto/src/openai_chat_json.zig @@ -117,7 +117,7 @@ pub fn serializeRequest( if (tools.count() > 0) { try s.objectField("tools"); try s.beginArray(); - var it = tools.iterator(); + var it = tools.toolsForLLM(); while (it.next()) |t| { try s.beginObject(); try s.objectField("type"); @@ -125,6 +125,7 @@ pub fn serializeRequest( try s.objectField("function"); try s.beginObject(); try s.objectField("name"); + // `t.decl.name` is already wire-encoded by `toolsForLLM`. try s.write(t.decl.name); try s.objectField("description"); try s.write(t.decl.description); @@ -261,7 +262,11 @@ fn writeMessage(s: *std.json.Stringify, msg: conversation.Message, allocator: Al try s.objectField("function"); try s.beginObject(); try s.objectField("name"); - try s.write(tu.name); + // Replayed assistant tool_use. The conversation stores the + // internal (dotted) name; encode `.` -> `__` so it matches + // what we advertise in `tools`. + var name_buf: [tool_registry_mod.max_wire_name_len]u8 = undefined; + try s.write(tool_registry_mod.encodeName(&name_buf, tu.name)); try s.objectField("arguments"); // `arguments` is a string carrying JSON, per the OpenAI // wire format — not a nested object. @@ -664,9 +669,7 @@ test "serializeRequest - emits tools array when registry non-empty" { var tools = emptyTools(); defer tools.deinit(); - try tools.register(makeStaticTool( - "echo", - "Echo a message back.", + try tools.register(makeStaticTool("echo", "Echo a message back.", \\{"type":"object","properties":{"message":{"type":"string"}},"required":["message"]} )); @@ -691,6 +694,29 @@ test "serializeRequest - emits tools array when registry non-empty" { try testing.expect(params.get("required").? == .array); } +test "serializeRequest - dotted tool name is wire-encoded with __" { + const allocator = testing.allocator; + + var conv = conversation.Conversation.init(allocator); + defer conv.deinit(); + try conv.addUserMessage("go"); + + var tools = emptyTools(); + defer tools.deinit(); + try tools.register(makeStaticTool("std.read", "Read a file.", "{\"type\":\"object\"}")); + + const cfg = testConfig("gpt-4o"); + const body = try serializeRequest(allocator, &cfg, &conv, &tools); + defer allocator.free(body); + + var parsed = try std.json.parseFromSlice(std.json.Value, allocator, body, .{}); + defer parsed.deinit(); + + const f = parsed.value.object.get("tools").?.array.items[0].object.get("function").?.object; + // Internal `std.read` crosses the wire as `std__read` (OpenAI forbids dots). + try testing.expectEqualStrings("std__read", f.get("name").?.string); +} + test "serializeRequest - assistant ToolUse becomes tool_calls" { const allocator = testing.allocator; diff --git a/libpanto/src/provider.zig b/libpanto/src/provider.zig index ab60678..1a8ee4e 100644 --- a/libpanto/src/provider.zig +++ b/libpanto/src/provider.zig @@ -88,59 +88,58 @@ pub const Receiver = struct { } }; -pub const ProviderVTable = struct { - streamStep: *const fn ( - *anyopaque, - *conversation.Conversation, - *const ToolRegistry, - *Receiver, - ) anyerror!void, - deinit: *const fn (*anyopaque) void, -}; - -pub const Provider = struct { - ptr: *anyopaque, - vtable: *const ProviderVTable, - - /// Construct the concrete provider implementation that matches `cfg`'s - /// API style, heap-allocate it, and return a `Provider` interface bound - /// to it. `deinit` tears down the impl and frees the allocation. - /// - /// The concrete provider types (`OpenAIChatProvider`, etc.) are - /// implementation details; callers interact only with this interface. - pub fn init( - allocator: std.mem.Allocator, - io: std.Io, - cfg: config_mod.Config, - ) !Provider { - // Imported lazily to break the circular module graph: - // provider.zig <- provider_openai_chat.zig <- provider.zig. - const provider_openai_chat = @import("provider_openai_chat.zig"); - const provider_anthropic_messages = @import("provider_anthropic_messages.zig"); - switch (cfg) { - .openai_chat => |c| { - const impl = try allocator.create(provider_openai_chat.OpenAIChatProvider); - impl.* = provider_openai_chat.OpenAIChatProvider.init(allocator, io, c); - return impl.provider(); - }, - .anthropic_messages => |c| { - const impl = try allocator.create(provider_anthropic_messages.AnthropicMessagesProvider); - impl.* = provider_anthropic_messages.AnthropicMessagesProvider.init(allocator, io, c); - return impl.provider(); - }, - } - } - - pub fn streamStep( - self: Provider, - conv: *conversation.Conversation, - tools: *const ToolRegistry, - receiver: *Receiver, - ) anyerror!void { - return self.vtable.streamStep(self.ptr, conv, tools, receiver); - } - - pub fn deinit(self: Provider) void { - self.vtable.deinit(self.ptr); +/// Drive one streaming provider turn against the active config snapshot. +/// +/// This is the single dispatch point: it switches on `cfg.provider`'s +/// `APIStyle` tag, builds a transient per-request object bound to the +/// process-global HTTP client, and runs it. There is no persistent +/// provider object — every turn re-reads `cfg`, so swapping the agent's +/// `*const Config` between turns changes provider, model, base_url, and +/// the visible tool set with no transport teardown. +/// +/// The tool registry is taken from `cfg.registry`; the serializers receive +/// it directly. +pub fn streamStep( + allocator: std.mem.Allocator, + io: std.Io, + cfg: *const config_mod.Config, + conv: *conversation.Conversation, + receiver: *Receiver, +) anyerror!void { + // Imported lazily to break the circular module graph: + // provider.zig <- provider_openai_chat.zig <- provider.zig. + const provider_openai_chat = @import("provider_openai_chat.zig"); + const provider_anthropic_messages = @import("provider_anthropic_messages.zig"); + const client = config_mod.httpClient(); + switch (cfg.provider) { + .openai_chat => |*c| { + var req: provider_openai_chat.OpenAIChatRequest = .{ + .allocator = allocator, + .io = io, + .config = c, + .http_client = client, + }; + return req.streamStep(conv, cfg.registry, receiver); + }, + .anthropic_messages => |*c| { + var req: provider_anthropic_messages.AnthropicMessagesRequest = .{ + .allocator = allocator, + .io = io, + .config = c, + .http_client = client, + }; + return req.streamStep(conv, cfg.registry, receiver); + }, } -}; +} + +/// The shape of `streamStep`, exposed as a function-pointer type so the +/// agent can carry an injectable seam (real dispatch in production, a stub +/// in tests) without resurrecting a per-provider vtable. +pub const StreamFn = *const fn ( + allocator: std.mem.Allocator, + io: std.Io, + cfg: *const config_mod.Config, + conv: *conversation.Conversation, + receiver: *Receiver, +) anyerror!void; diff --git a/libpanto/src/provider_anthropic_messages.zig b/libpanto/src/provider_anthropic_messages.zig index 5d5520a..02277be 100644 --- a/libpanto/src/provider_anthropic_messages.zig +++ b/libpanto/src/provider_anthropic_messages.zig @@ -23,62 +23,19 @@ const provider_mod = @import("provider.zig"); const sse_mod = @import("sse.zig"); const json_mod = @import("anthropic_messages_json.zig"); const config_mod = @import("config.zig"); +const tool_registry_mod = @import("tool_registry.zig"); -pub const AnthropicMessagesProvider = struct { +/// A single Anthropic Messages streaming request. Transient: constructed +/// per `streamStep`, holds only borrowed state (allocator, io, the global +/// HTTP client, and the active config). Carries nothing across requests. +pub const AnthropicMessagesRequest = struct { allocator: Allocator, io: Io, - config: config_mod.AnthropicMessagesConfig, - http_client: http.Client, - - pub fn init( - allocator: Allocator, - io: Io, - cfg: config_mod.AnthropicMessagesConfig, - ) AnthropicMessagesProvider { - return .{ - .allocator = allocator, - .io = io, - .config = cfg, - .http_client = .{ .allocator = allocator, .io = io }, - }; - } - - pub fn deinit(self: *AnthropicMessagesProvider) void { - self.http_client.deinit(); - } - - pub fn provider(self: *AnthropicMessagesProvider) provider_mod.Provider { - return .{ .ptr = self, .vtable = &vtable }; - } - - const vtable: provider_mod.ProviderVTable = .{ - .streamStep = vtableStreamStep, - .deinit = vtableDeinit, - }; - - fn vtableStreamStep( - ptr: *anyopaque, - conv: *conversation.Conversation, - tools: *const provider_mod.ToolRegistry, - receiver: *provider_mod.Receiver, - ) anyerror!void { - const self: *AnthropicMessagesProvider = @ptrCast(@alignCast(ptr)); - return self.streamStep(conv, tools, receiver); - } - - /// Called via the `Provider` interface. Tears down the impl AND frees - /// its heap allocation, since `Provider.init` is the one that allocated - /// it. Direct stack-allocated users (tests, embedders) call `deinit` - /// themselves and never hit this path. - fn vtableDeinit(ptr: *anyopaque) void { - const self: *AnthropicMessagesProvider = @ptrCast(@alignCast(ptr)); - const allocator = self.allocator; - self.deinit(); - allocator.destroy(self); - } + config: *const config_mod.AnthropicMessagesConfig, + http_client: *http.Client, pub fn streamStep( - self: *AnthropicMessagesProvider, + self: *AnthropicMessagesRequest, conv: *conversation.Conversation, tools: *const provider_mod.ToolRegistry, receiver: *provider_mod.Receiver, @@ -92,7 +49,7 @@ pub const AnthropicMessagesProvider = struct { } fn streamStepInner( - self: *AnthropicMessagesProvider, + self: *AnthropicMessagesRequest, conv: *conversation.Conversation, tools: *const provider_mod.ToolRegistry, receiver: *provider_mod.Receiver, @@ -106,7 +63,7 @@ pub const AnthropicMessagesProvider = struct { const uri = try Uri.parse(url); - const body = try json_mod.serializeRequest(self.allocator, &self.config, conv, tools); + const body = try json_mod.serializeRequest(self.allocator, self.config, conv, tools); defer self.allocator.free(body); const extra_headers = [_]http.Header{ @@ -294,10 +251,25 @@ const StreamState = struct { .kind = kind, }; // For tool_use blocks, capture the identity fields. Anthropic - // delivers both whole on content_block_start. + // delivers both whole on content_block_start. The wire name is + // encoded (`__` for `.`); decode it here so everything downstream + // — onToolDetails, the stored ContentBlock, session logs, and + // dispatch — sees the internal (dotted) name. The decoded form is + // never longer than the wire form. if (kind == .tool_use) { if (tool_id) |id| ab.tool_id = try self.allocator.dupe(u8, id); - if (tool_name) |n| ab.tool_name = try self.allocator.dupe(u8, n); + if (tool_name) |n| { + // Decode `__` -> `.` into an exact-size owned buffer so the + // stored slice is freeable as a whole allocation. + const owned = try self.allocator.alloc(u8, n.len); + errdefer self.allocator.free(owned); + const decoded = tool_registry_mod.decodeName(owned, n); + if (decoded.len == n.len) { + ab.tool_name = owned; + } else { + ab.tool_name = try self.allocator.realloc(owned, decoded.len); + } + } } self.active = ab; const block_type: ?provider_mod.ContentBlockType = switch (kind) { @@ -933,6 +905,37 @@ test "tool_use blocks are captured with id, name, and assembled input" { try testing.expectEqualStrings("done", asst.content.items[1].Text.items); } +test "inbound wire tool name is decoded to dotted form" { + // Anthropic delivers the (wire-encoded) name whole at + // content_block_start; it is decoded to the internal dotted form for + // the conversation, session logs, and dispatch. + const allocator = testing.allocator; + + var conv = conversation.Conversation.init(allocator); + defer conv.deinit(); + try conv.addUserMessage("use a tool"); + + var rec = RecordingReceiver.init(allocator); + defer rec.deinit(); + var recv = rec.receiver(); + + const events = [_][]const u8{ + \\{"type":"message_start","message":{"role":"assistant"}} + , + \\{"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"t1","name":"calc__sum","input":{}}} + , + \\{"type":"content_block_stop","index":0} + , + \\{"type":"message_stop"} + , + }; + + try runStreamedTurn(allocator, &conv, &recv, &events); + + const tu = conv.messages.items[1].content.items[0].ToolUse; + try testing.expectEqualStrings("calc.sum", tu.name); +} + test "error event propagates as Zig error" { const allocator = testing.allocator; diff --git a/libpanto/src/provider_openai_chat.zig b/libpanto/src/provider_openai_chat.zig index fcbc0bb..d70eb30 100644 --- a/libpanto/src/provider_openai_chat.zig +++ b/libpanto/src/provider_openai_chat.zig @@ -21,63 +21,34 @@ const provider_mod = @import("provider.zig"); const sse_mod = @import("sse.zig"); const json_mod = @import("openai_chat_json.zig"); const config_mod = @import("config.zig"); +const tool_registry_mod = @import("tool_registry.zig"); + +/// Decode a wire tool name (`__` -> `.`) in place within an assembled +/// name buffer. Decoding only ever shrinks the buffer (reads stay ahead +/// of writes), so aliasing src/dst is safe; we then truncate to the +/// decoded length. Unambiguous because internal names never contain a +/// literal `__` (enforced at registration). +fn decodeNameInPlace(name_buf: *conversation.TextualBlock) void { + const decoded = tool_registry_mod.decodeName(name_buf.items, name_buf.items); + name_buf.items.len = decoded.len; +} /// Active streaming block type tracked by the state machine. Mirrors the /// `ContentBlock` union variants but adds `.none` for "no block open yet". const ActiveBlock = enum { none, text, thinking, tool_use }; -pub const OpenAIChatProvider = struct { +/// A single OpenAI Chat streaming request. Transient: constructed per +/// `streamStep`, holds only borrowed state (allocator, io, the global HTTP +/// client, and the active config). Carries nothing across requests, so it +/// is created inline by the free `streamStep` entry point below. +pub const OpenAIChatRequest = struct { allocator: Allocator, io: Io, - config: config_mod.OpenAIChatConfig, - http_client: http.Client, - - pub fn init(allocator: Allocator, io: Io, cfg: config_mod.OpenAIChatConfig) OpenAIChatProvider { - return .{ - .allocator = allocator, - .io = io, - .config = cfg, - .http_client = .{ .allocator = allocator, .io = io }, - }; - } - - pub fn deinit(self: *OpenAIChatProvider) void { - self.http_client.deinit(); - } - - /// Return a `Provider` interface bound to this concrete provider. - pub fn provider(self: *OpenAIChatProvider) provider_mod.Provider { - return .{ .ptr = self, .vtable = &vtable }; - } - - const vtable: provider_mod.ProviderVTable = .{ - .streamStep = vtableStreamStep, - .deinit = vtableDeinit, - }; - - fn vtableStreamStep( - ptr: *anyopaque, - conv: *conversation.Conversation, - tools: *const provider_mod.ToolRegistry, - receiver: *provider_mod.Receiver, - ) anyerror!void { - const self: *OpenAIChatProvider = @ptrCast(@alignCast(ptr)); - return self.streamStep(conv, tools, receiver); - } - - /// Called via the `Provider` interface. Tears down the impl AND frees - /// its heap allocation, since `Provider.init` is the one that allocated - /// it. Direct stack-allocated users (tests, embedders) call `deinit` - /// themselves and never hit this path. - fn vtableDeinit(ptr: *anyopaque) void { - const self: *OpenAIChatProvider = @ptrCast(@alignCast(ptr)); - const allocator = self.allocator; - self.deinit(); - allocator.destroy(self); - } + config: *const config_mod.OpenAIChatConfig, + http_client: *http.Client, pub fn streamStep( - self: *OpenAIChatProvider, + self: *OpenAIChatRequest, conv: *conversation.Conversation, tools: *const provider_mod.ToolRegistry, receiver: *provider_mod.Receiver, @@ -92,7 +63,7 @@ pub const OpenAIChatProvider = struct { } fn streamStepInner( - self: *OpenAIChatProvider, + self: *OpenAIChatRequest, conv: *conversation.Conversation, tools: *const provider_mod.ToolRegistry, receiver: *provider_mod.Receiver, @@ -108,7 +79,7 @@ pub const OpenAIChatProvider = struct { const uri = try Uri.parse(url); // Build the request body. - const body = try json_mod.serializeRequest(self.allocator, &self.config, conv, tools); + const body = try json_mod.serializeRequest(self.allocator, self.config, conv, tools); defer self.allocator.free(body); // Auth header @@ -490,6 +461,12 @@ const StreamState = struct { return; } + // The model echoes the wire-encoded tool name (`__` for `.`). + // Decode in place now that the full name is assembled, so the + // conversation, receiver callbacks, and dispatch all see the + // internal (dotted) name. Decoding never grows the buffer. + decodeNameInPlace(&tu.name_buf); + // If no arguments ever arrived, we haven't emitted onBlockStart // yet — do it now so the receiver sees a balanced start/complete. try self.emitStartIfNeeded(receiver, &tu); @@ -640,7 +617,7 @@ const testing = std.testing; /// about post-stream conversation state rather than callback observability. const NoopReceiver = struct { fn make() provider_mod.Receiver { - return .{ .ptr = @constCast(@ptrCast(&dummy)), .vtable = &vt }; + return .{ .ptr = @ptrCast(@constCast(&dummy)), .vtable = &vt }; } var dummy: u8 = 0; const vt: provider_mod.ReceiverVTable = .{ @@ -676,7 +653,7 @@ fn runStreamedTurn( if (std.mem.eql(u8, payload, "[DONE]")) break; // Process every chunk through to [DONE], including the // post-finish_reason usage chunk. Mirrors the production loop - // in OpenAIChatProvider.streamStep. + // in OpenAIChatRequest.streamStep. try handleEvent(allocator, payload, &state, receiver); } try state.finalize(receiver, conv); @@ -847,6 +824,39 @@ test "fragmented tool_call id and name are reassembled" { try testing.expectEqualStrings("{\"host\":\"a.com\"}", tu.input.items); } +test "inbound wire tool name is decoded to dotted form (even split across __)" { + // The model echoes the wire name it was given (`std__read`). It is + // decoded to the internal `std.read` for the conversation/session/ + // dispatch. The decode happens after full assembly, so a `__` split + // across two deltas (`std_` + `_read`) decodes correctly. + const allocator = testing.allocator; + + var conv = conversation.Conversation.init(allocator); + defer conv.deinit(); + try conv.addUserMessage("read a file"); + + var recv = NoopReceiver.make(); + + const events = [_][]const u8{ + \\{"choices":[{"delta":{"role":"assistant"}}]} + , + \\{"choices":[{"delta":{"tool_calls":[{"index":0,"id":"c1","type":"function","function":{"name":"std_"}}]}}]} + , + \\{"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"name":"_read"}}]}}]} + , + \\{"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{}"}}]}}]} + , + \\{"choices":[{"delta":{},"finish_reason":"tool_calls"}]} + , + "[DONE]", + }; + + try runStreamedTurn(allocator, &conv, &recv, &events); + + const tu = conv.messages.items[1].content.items[0].ToolUse; + try testing.expectEqualStrings("std.read", tu.name); +} + /// A Receiver that records the sequence of callback events as compact /// strings. Useful for asserting per-block start/complete ordering. const RecordingReceiver = struct { diff --git a/libpanto/src/root.zig b/libpanto/src/root.zig index de62cfa..68f8e7d 100644 --- a/libpanto/src/root.zig +++ b/libpanto/src/root.zig @@ -19,11 +19,15 @@ pub const ToolDecl = tool_source.ToolDecl; pub const ToolCall = tool_source.Call; pub const ToolCallResult = tool_source.CallResult; pub const ToolRegistry = tool_registry.ToolRegistry; +pub const Config = config.Config; +pub const ProviderConfig = config.ProviderConfig; -// Internal modules. Not part of the public API — callers construct providers -// via `provider.Provider.init(allocator, io, config)`, which picks the right -// concrete implementation based on the config's API style. These are exposed -// here only so `refAllDecls` picks up their tests. +// Internal modules. Not part of the public API — callers drive turns via +// `provider.streamStep(allocator, io, &config, conv, receiver)` (or via the +// `Agent`, which holds a swappable `*const Config`). The process-global HTTP +// client is initialized with `config.initHttp` / torn down with +// `config.deinitHttp`. These impls are exposed here only so `refAllDecls` +// picks up their tests. const openai_chat_json = @import("openai_chat_json.zig"); const provider_openai_chat = @import("provider_openai_chat.zig"); const anthropic_messages_json = @import("anthropic_messages_json.zig"); 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); +} -- cgit v1.3