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 ++++++++++++++++++++++++++++++++----------------- 1 file changed, 250 insertions(+), 133 deletions(-) (limited to 'libpanto/src/agent.zig') 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); +} -- cgit v1.3