summaryrefslogtreecommitdiff
path: root/libpanto/src/agent.zig
diff options
context:
space:
mode:
authort <t@tjp.lol>2026-06-01 08:21:00 -0600
committert <t@tjp.lol>2026-06-01 11:20:56 -0600
commit1beefefc69beee214430eb5bd2528a4f5692d2a8 (patch)
treea459dbde5da17babe7d872999341d2006ebfe02e /libpanto/src/agent.zig
parent5c016cfdbcb122e2b4f0496ef946642d68953c4b (diff)
Rename system extension layer to base for clarity
Replace all references to the "system" layer with "base" to better reflect its role as the foundational extension/tool layer in panto's hierarchy. The layer hierarchy now consistently uses: project > user > base. Includes: - Update agent README and build.zig documentation - Refactor tool registry and config module to support layered lookups - Add TestHarness abstraction for cleaner test setup - Improve JSON serialization with wire-encoded tool names - Add glob pattern matching for tool/extension discovery
Diffstat (limited to 'libpanto/src/agent.zig')
-rw-r--r--libpanto/src/agent.zig381
1 files changed, 249 insertions, 132 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);
+}