summaryrefslogtreecommitdiff
path: root/libpanto/src
diff options
context:
space:
mode:
Diffstat (limited to 'libpanto/src')
-rw-r--r--libpanto/src/agent.zig381
-rw-r--r--libpanto/src/anthropic_messages_json.zig47
-rw-r--r--libpanto/src/config.zig118
-rw-r--r--libpanto/src/openai_chat_json.zig36
-rw-r--r--libpanto/src/provider.zig107
-rw-r--r--libpanto/src/provider_anthropic_messages.zig113
-rw-r--r--libpanto/src/provider_openai_chat.zig114
-rw-r--r--libpanto/src/root.zig12
-rw-r--r--libpanto/src/tool_registry.zig169
9 files changed, 775 insertions, 322 deletions
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,
+ /// 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, prov: provider_mod.Provider) Agent {
+ 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();
- }
-
- /// Register a single tool. The agent's registry takes ownership.
- pub fn registerTool(self: *Agent, tool: Tool) !void {
- try self.registry.register(tool);
+ // The agent owns neither the config snapshot nor the registry it
+ // borrows; the embedder tears those down.
+ _ = self;
}
- /// Register a tool source. The agent's registry takes ownership.
- pub fn registerToolSource(self: *Agent, src: ToolSource) !void {
- try self.registry.registerSource(src);
+ /// 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;
}
- /// 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 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;
+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,
- } });
- },
- }
+ 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);
}
+ const moved = try blocks.toOwnedSlice(self.allocator);
+ defer self.allocator.free(moved);
+ try conv.addAssistantMessage(moved);
+}
- fn vtDeinit(_: *anyopaque) void {}
+/// 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,
+
+ fn init(allocator: Allocator) TestHarness {
+ return .{ .registry = ToolRegistry.init(allocator), .config = undefined };
+ }
+
+ /// 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 = &reg_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 = &reg_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 = &reg,
+ };
+ 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);
+/// 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);
+ },
}
+}
- pub fn deinit(self: Provider) void {
- self.vtable.deinit(self.ptr);
- }
-};
+/// 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);
+}