diff options
| author | t <t@tjp.lol> | 2026-06-07 10:57:49 -0600 |
|---|---|---|
| committer | t <t@tjp.lol> | 2026-06-07 10:57:49 -0600 |
| commit | d36a51358efbc48de3d5b732727455efc60acae1 (patch) | |
| tree | 21d6218c1c8d4b7aad706d70f65449019edc6b87 | |
| parent | 032d27b18597ee3f10222963b53625208c84f571 (diff) | |
R1: move tool registry off Config onto Agent
The tool set is no longer part of the per-turn Config snapshot. Agent now
owns a ToolRegistry (created empty at init), populated via the new
Agent.registerTool / registerToolSource methods. openStream and
OpenStreamFn take the registry as an explicit parameter rather than reading
it from cfg.registry, so swapping provider/model between turns (setConfig)
no longer disturbs the tool set.
CLI migrated to register the Lua tool source onto the agent instead of a
locally-owned registry. Test harnesses updated to stage tools on the agent.
| -rw-r--r-- | libpanto/src/agent.zig | 149 | ||||
| -rw-r--r-- | libpanto/src/config.zig | 41 | ||||
| -rw-r--r-- | libpanto/src/provider.zig | 12 | ||||
| -rw-r--r-- | src/main.zig | 17 | ||||
| -rw-r--r-- | src/system_prompt.zig | 13 |
5 files changed, 128 insertions, 104 deletions
diff --git a/libpanto/src/agent.zig b/libpanto/src/agent.zig index 6749ffd..e8be325 100644 --- a/libpanto/src/agent.zig +++ b/libpanto/src/agent.zig @@ -184,10 +184,15 @@ pub const Agent = struct { io: Io, /// 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. + /// (`setConfig`) between turns to change provider/model/base_url + /// atomically. The pointee is owned by the embedder, not the agent. The + /// tool set is no longer part of the snapshot — it lives on `registry`. config: *const Config, + /// The tool set this agent exposes. Owned by the agent: created empty at + /// `init`, populated via `registerTool`/`registerToolSource`, torn down + /// in `deinit`. Read fresh by the agent loop each turn, so a + /// registration between turns is visible at the next turn boundary. + registry: ToolRegistry, /// The live conversation the agent drives. Owned by the agent (adopted /// at `init`); torn down in `deinit`. Turn-driving methods operate on /// this directly rather than taking a `*Conversation` parameter. @@ -241,18 +246,31 @@ pub const Agent = struct { .allocator = allocator, .io = io, .config = config, + .registry = ToolRegistry.init(allocator), .conversation = maybe_conversation orelse conversation.Conversation.init(allocator), .session_store = store, }; } pub fn deinit(self: *Agent) void { - // The agent owns the conversation; it borrows the config snapshot, - // the registry, and the session store, which the embedder tears - // down. + // The agent owns the conversation and the tool registry; it borrows + // the config snapshot and the session store, which the embedder + // tears down. + self.registry.deinit(); self.conversation.deinit(); } + /// Add a single tool to this agent's tool set. Visible at the next turn. + pub fn registerTool(self: *Agent, tool: Tool) !void { + try self.registry.register(tool); + } + + /// Add a tool source (a dynamic group of tools) to this agent's tool + /// set. Visible at the next turn. + pub fn registerToolSource(self: *Agent, src: ToolSource) !void { + try self.registry.registerSource(src); + } + /// The provider/model identity used to stamp persisted entries. Uses /// the embedder-supplied display names when set, falling back to the /// active config snapshot's model string (and the `APIStyle` tag name @@ -279,10 +297,7 @@ pub const Agent = struct { self.config = config; } - /// The registry exposed by the active snapshot. - pub fn registry(self: *const Agent) *const ToolRegistry { - return self.config.registry; - } + /// Add a system message (append or replace mode) to the conversation /// and persist it. The persisted entry records the mode so replay @@ -418,7 +433,7 @@ pub const Agent = struct { var attempt: usize = 1; while (true) { var diag: provider_mod.ProviderDiagnostic = .{}; - const ps = self.open_stream_fn(self.allocator, self.io, cfg, &self.conversation, &diag) catch |err| { + const ps = self.open_stream_fn(self.allocator, self.io, cfg, &self.registry, &self.conversation, &diag) catch |err| { if (err == error.ContextOverflow) { return self.handleContextOverflow(cfg, out, err); } @@ -471,7 +486,7 @@ pub const Agent = struct { } }); // Retry the same request against the compacted context. var diag: provider_mod.ProviderDiagnostic = .{}; - return self.open_stream_fn(self.allocator, self.io, cfg, &self.conversation, &diag); + return self.open_stream_fn(self.allocator, self.io, cfg, &self.registry, &self.conversation, &diag); } /// Compute the backoff delay (ms) for the just-failed `attempt` @@ -747,10 +762,9 @@ pub const Agent = struct { if (self.config.compaction.model) |comp_provider| { const cfg: config_mod.Config = .{ .provider = comp_provider, - .registry = &empty_registry, .compaction = self.config.compaction, }; - if (self.runSingleCompactionTurn(&cfg, sys_text, body)) |summary| { + if (self.runSingleCompactionTurn(&cfg, &empty_registry, sys_text, body)) |summary| { return summary; } else |err| { std.log.warn("compaction model failed ({t}); falling back to active model", .{err}); @@ -759,10 +773,9 @@ pub const Agent = struct { const cfg: config_mod.Config = .{ .provider = self.config.provider, - .registry = &empty_registry, .compaction = self.config.compaction, }; - return self.runSingleCompactionTurn(&cfg, sys_text, body); + return self.runSingleCompactionTurn(&cfg, &empty_registry, sys_text, body); } /// A generated compaction summary plus the token size to attribute to @@ -782,6 +795,7 @@ pub const Agent = struct { fn runSingleCompactionTurn( self: *Agent, cfg: *const config_mod.Config, + registry: *const ToolRegistry, system_prompt: []const u8, body: []const u8, ) !CompactionSummary { @@ -797,7 +811,7 @@ pub const Agent = struct { // stream until it commits the assistant message. var queue = stream_mod.EventQueue.init(alloc); defer queue.deinit(); - var ps = try self.open_stream_fn(alloc, self.io, cfg, &conv, null); + var ps = try self.open_stream_fn(alloc, self.io, cfg, registry, &conv, null); defer ps.deinit(); while (true) { const status = try ps.produce(&queue); @@ -855,7 +869,7 @@ pub const Agent = struct { }); continue; } - const entry = self.config.registry.lookup(tu.name) orelse { + const entry = self.registry.lookup(tu.name) orelse { // Unknown tool: don't abort. Synthesize an error result so // the model can correct, and so this ToolUse still gets its // matching ToolResult (providers reject a follow-up request @@ -1572,6 +1586,7 @@ fn stubOpenStream( allocator: Allocator, _: Io, _: *const config_mod.Config, + _: *const ToolRegistry, conv: *conversation.Conversation, diag: ?*provider_mod.ProviderDiagnostic, ) anyerror!provider_mod.ProviderStream { @@ -1596,9 +1611,11 @@ fn stubOpenStream( return StubResponse.create(allocator, conv, turn); } -/// 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. +/// Build a stack registry + active `Config` snapshot for tests that drive +/// the agent. Post-R1 the registry no longer lives on `Config` — it lives +/// on the `Agent`. The harness still owns a registry so tests can pre-stage +/// tools and copy them onto the agent (`seed`) after `init`. The caller +/// owns both and must keep them alive for the agent's lifetime. const TestHarness = struct { registry: ToolRegistry, config: config_mod.Config, @@ -1607,16 +1624,25 @@ const TestHarness = struct { 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. + /// Finalize the config snapshot. 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, }; } + /// Move the tools pre-staged on the harness registry onto a freshly + /// `init`ed agent's own registry. Post-R1 the agent owns its tool set, + /// so tests stage tools on the harness then transplant them here. The + /// agent's empty registry is freed and replaced; the harness registry + /// is left empty (its `deinit` becomes a no-op free). + fn seedInto(self: *TestHarness, agent: *Agent) void { + agent.registry.deinit(); + agent.registry = self.registry; + self.registry = ToolRegistry.init(self.registry.allocator); + } + fn deinit(self: *TestHarness) void { self.registry.deinit(); } @@ -1856,6 +1882,7 @@ test "agent persists user, assistant, and tool-result messages of a turn" { defer cap.deinit(); var agent = Agent.init(allocator, io, &h.config, cap.store(), null); defer agent.deinit(); + h.seedInto(&agent); agent.open_stream_fn = stub.install(); agent.persist_provider = "openai"; agent.persist_model = "gpt-4o"; @@ -1894,6 +1921,7 @@ test "agent runs a turn against NullStore without persisting or erroring" { var ns = null_store_mod.NullStore.init(allocator); var agent = Agent.init(allocator, io, &h.config, ns.store(), null); defer agent.deinit(); + h.seedInto(&agent); agent.open_stream_fn = stub.install(); try drainTurn(&agent, "hello"); @@ -2183,6 +2211,7 @@ test "runStep dispatches a tool call and loops to a final text turn" { var ns = null_store_mod.NullStore.init(allocator); var agent = Agent.init(allocator, io, &h.config, ns.store(), null); defer agent.deinit(); + h.seedInto(&agent); agent.open_stream_fn = stub.install(); const conv = &agent.conversation; @@ -2233,6 +2262,7 @@ test "runStep dispatches multiple tool calls in parallel" { var ns = null_store_mod.NullStore.init(allocator); var agent = Agent.init(allocator, io, &h.config, ns.store(), null); defer agent.deinit(); + h.seedInto(&agent); agent.open_stream_fn = stub.install(); const conv = &agent.conversation; @@ -2272,6 +2302,7 @@ test "runStep: native tool handler error becomes an error result and the model g var ns = null_store_mod.NullStore.init(allocator); var agent = Agent.init(allocator, io, &h.config, ns.store(), null); defer agent.deinit(); + h.seedInto(&agent); agent.open_stream_fn = stub.install(); const conv = &agent.conversation; @@ -2305,6 +2336,7 @@ test "runStep: unknown tool becomes an error tool result and the loop continues" var ns = null_store_mod.NullStore.init(allocator); var agent = Agent.init(allocator, io, &h.config, ns.store(), null); defer agent.deinit(); + h.seedInto(&agent); agent.open_stream_fn = stub.install(); const conv = &agent.conversation; @@ -2337,6 +2369,7 @@ test "runStep with no tool calls returns after one provider step" { var ns = null_store_mod.NullStore.init(allocator); var agent = Agent.init(allocator, io, &h.config, ns.store(), null); defer agent.deinit(); + h.seedInto(&agent); agent.open_stream_fn = stub.install(); const conv = &agent.conversation; @@ -2363,6 +2396,7 @@ test "runStep surfaces EmptyAssistantResponse when provider commits an empty mes var ns = null_store_mod.NullStore.init(allocator); var agent = Agent.init(allocator, io, &h.config, ns.store(), null); defer agent.deinit(); + h.seedInto(&agent); agent.open_stream_fn = stub.install(); @@ -2393,6 +2427,7 @@ test "runStep delivers all source-backed calls in one batch on one thread" { var ns = null_store_mod.NullStore.init(allocator); var agent = Agent.init(allocator, io, &h.config, ns.store(), null); defer agent.deinit(); + h.seedInto(&agent); agent.open_stream_fn = stub.install(); const conv = &agent.conversation; @@ -2400,7 +2435,7 @@ test "runStep delivers all source-backed calls in one batch on one thread" { try drainTurn(&agent, "go"); // Locate the source and inspect its observed batches. - const view = h.registry.lookup("lua_x") orelse return error.NotFound; + const view = agent.registry.lookup("lua_x") orelse return error.NotFound; const src_ptr = view.entry.source.source; const test_src: *TestSource = @ptrCast(@alignCast(src_ptr.ctx)); @@ -2444,13 +2479,14 @@ test "runStep: distinct sources run on distinct threads in parallel" { var ns = null_store_mod.NullStore.init(allocator); var agent = Agent.init(allocator, io, &h.config, ns.store(), null); defer agent.deinit(); + h.seedInto(&agent); agent.open_stream_fn = stub.install(); try drainTurn(&agent, "go"); - 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 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 sa: *TestSource = @ptrCast(@alignCast(view_a.entry.source.source.ctx)); const sb: *TestSource = @ptrCast(@alignCast(view_b.entry.source.source.ctx)); @@ -2481,6 +2517,7 @@ test "runStep: source whole-batch error becomes per-call error results and conti var ns = null_store_mod.NullStore.init(allocator); var agent = Agent.init(allocator, io, &h.config, ns.store(), null); defer agent.deinit(); + h.seedInto(&agent); agent.open_stream_fn = stub.install(); const conv = &agent.conversation; @@ -2524,6 +2561,7 @@ test "runStep: mixed single Tools and source-backed tools coexist in one turn" { var ns = null_store_mod.NullStore.init(allocator); var agent = Agent.init(allocator, io, &h.config, ns.store(), null); defer agent.deinit(); + h.seedInto(&agent); agent.open_stream_fn = stub.install(); const conv = &agent.conversation; @@ -2537,12 +2575,11 @@ test "runStep: mixed single Tools and source-backed tools coexist in one turn" { try testing.expectEqualStrings("src_t2->Z", trText(tr_msg.content.items[2].ToolResult)); } -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. +test "setConfig swaps provider between turns; agent tool set persists" { + // Post-R1 the tool set lives on the `Agent`, not on `Config`. Swapping + // the config pointer (`setConfig`) changes provider/model at the next + // turn boundary but leaves the agent's registered tools intact: a turn + // after the swap still resolves a tool registered before it. const allocator = testing.allocator; const scripted = [_]StubProvider.ScriptedTurn{ @@ -2554,40 +2591,26 @@ test "setConfig swaps the visible tool set between turns" { 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, + .provider = .{ .openai_chat = .{ .api_key = "k", .base_url = "a", .model = "m" } }, }; - - // 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, + .provider = .{ .openai_chat = .{ .api_key = "k", .base_url = "b", .model = "m" } }, }; var ns = null_store_mod.NullStore.init(allocator); var agent = Agent.init(allocator, io, &cfg_a, ns.store(), null); defer agent.deinit(); + try agent.registerTool(try EchoTool.create(allocator, "late", "B:")); agent.open_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. + // The tool is visible regardless of which config is active. + try testing.expect(agent.registry.lookup("late") != null); agent.setConfig(&cfg_b); - try testing.expect(agent.config.registry.lookup("echo") == null); - try testing.expect(agent.config.registry.lookup("late") != null); + try testing.expect(agent.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. + // A real turn after the swap still resolves `late`, then loops to the + // final text turn. const conv = &agent.conversation; try drainTurn(&agent, "go"); @@ -2617,6 +2640,7 @@ test "compact: summarizes prefix, keeps suffix, system survives" { var ns = null_store_mod.NullStore.init(allocator); var agent = Agent.init(allocator, io, &h.config, ns.store(), null); defer agent.deinit(); + h.seedInto(&agent); agent.open_stream_fn = stub.install(); const conv = &agent.conversation; @@ -2679,6 +2703,7 @@ test "compact: restated suffix usage reconstructs a fresh cumulative chain" { var ns = null_store_mod.NullStore.init(allocator); var agent = Agent.init(allocator, io, &h.config, ns.store(), null); defer agent.deinit(); + h.seedInto(&agent); agent.open_stream_fn = stub.install(); const conv = &agent.conversation; @@ -2752,6 +2777,7 @@ test "compact: no-op when conversation already fits the budget" { var ns = null_store_mod.NullStore.init(allocator); var agent = Agent.init(allocator, io, &h.config, ns.store(), null); defer agent.deinit(); + h.seedInto(&agent); agent.open_stream_fn = stub.install(); const conv = &agent.conversation; @@ -2789,6 +2815,7 @@ test "compact: extra instructions are appended to the system prompt" { var ns = null_store_mod.NullStore.init(allocator); var agent = Agent.init(allocator, io, &h.config, ns.store(), null); defer agent.deinit(); + h.seedInto(&agent); agent.open_stream_fn = stub.install(); const conv = &agent.conversation; @@ -2829,6 +2856,7 @@ test "runStep: auto-compacts on context overflow and retries once" { var ns = null_store_mod.NullStore.init(allocator); var agent = Agent.init(allocator, io, &h.config, ns.store(), null); defer agent.deinit(); + h.seedInto(&agent); agent.open_stream_fn = stub.install(); agent.compaction_system_prompt = "Summarize the conversation."; @@ -2873,6 +2901,7 @@ test "runStep: context overflow without compaction prompt propagates" { var ns = null_store_mod.NullStore.init(allocator); var agent = Agent.init(allocator, io, &h.config, ns.store(), null); defer agent.deinit(); + h.seedInto(&agent); agent.open_stream_fn = stub.install(); // No compaction_system_prompt set -> overflow propagates. @@ -2944,6 +2973,7 @@ test "runStep: provider 429 retries then succeeds without duplicate messages" { var ns = null_store_mod.NullStore.init(allocator); var agent = Agent.init(allocator, io, &h.config, ns.store(), null); defer agent.deinit(); + h.seedInto(&agent); agent.open_stream_fn = stub.install(); const conv = &agent.conversation; @@ -2987,6 +3017,7 @@ test "runStep: provider 500 retries with backoff notification" { var ns = null_store_mod.NullStore.init(allocator); var agent = Agent.init(allocator, io, &h.config, ns.store(), null); defer agent.deinit(); + h.seedInto(&agent); agent.open_stream_fn = stub.install(); @@ -3024,6 +3055,7 @@ test "runStep: provider auth failure does not retry" { var ns = null_store_mod.NullStore.init(allocator); var agent = Agent.init(allocator, io, &h.config, ns.store(), null); defer agent.deinit(); + h.seedInto(&agent); agent.open_stream_fn = stub.install(); @@ -3062,6 +3094,7 @@ test "runStep: retries exhaust and hard-fail after max_attempts" { var ns = null_store_mod.NullStore.init(allocator); var agent = Agent.init(allocator, io, &h.config, ns.store(), null); defer agent.deinit(); + h.seedInto(&agent); agent.open_stream_fn = stub.install(); @@ -3099,6 +3132,7 @@ test "runStep: Retry-After is honored and reported" { var ns = null_store_mod.NullStore.init(allocator); var agent = Agent.init(allocator, io, &h.config, ns.store(), null); defer agent.deinit(); + h.seedInto(&agent); agent.open_stream_fn = stub.install(); @@ -3133,6 +3167,7 @@ test "runStep: cancellation from a tool still hard-fails" { var ns = null_store_mod.NullStore.init(allocator); var agent = Agent.init(allocator, io, &h.config, ns.store(), null); defer agent.deinit(); + h.seedInto(&agent); agent.open_stream_fn = stub.install(); const conv = &agent.conversation; @@ -3163,6 +3198,7 @@ test "runStep: source per-call error produces a per-call error result and contin var ns = null_store_mod.NullStore.init(allocator); var agent = Agent.init(allocator, io, &h.config, ns.store(), null); defer agent.deinit(); + h.seedInto(&agent); agent.open_stream_fn = stub.install(); const conv = &agent.conversation; @@ -3201,6 +3237,7 @@ test "runStep: context-overflow compaction fires a compaction retry notification var ns = null_store_mod.NullStore.init(allocator); var agent = Agent.init(allocator, io, &h.config, ns.store(), null); defer agent.deinit(); + h.seedInto(&agent); agent.open_stream_fn = stub.install(); agent.compaction_system_prompt = "Summarize the conversation."; diff --git a/libpanto/src/config.zig b/libpanto/src/config.zig index eca81ff..8efa810 100644 --- a/libpanto/src/config.zig +++ b/libpanto/src/config.zig @@ -5,20 +5,20 @@ //! 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. +//! `Config` carries the active `ProviderConfig` (plus retry/compaction +//! policy). 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, +//! and base_url 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. +//! +//! The tool set is **not** part of `Config` — it lives on the `Agent` +//! (`Agent.registerTool`/`registerToolSource`), so swapping provider/model +//! between turns no longer means rebuilding the tool list. 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 { @@ -106,17 +106,13 @@ pub const RetryConfig = struct { jitter: bool = true, }; -/// 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. +/// An immutable snapshot of the provider/model the agent talks to, plus +/// retry and compaction policy. The agent holds a `*const Config` and +/// re-reads it each turn; replacing the pointer swaps the active +/// configuration at the next turn boundary. The tool set is owned by the +/// `Agent`, not the snapshot, so a swap never touches tools. pub const Config = struct { provider: ProviderConfig, - registry: *const ToolRegistry, compaction: CompactionConfig = .{}, retry: RetryConfig = .{}, @@ -196,12 +192,9 @@ test "ProviderConfig - openai_chat reasoning defaults to .default" { 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(); +test "Config carries provider and forwards style" { const cfg: Config = .{ .provider = .{ .openai_chat = .{ .api_key = "k", .base_url = "u", .model = "m" } }, - .registry = ®, }; try t.expectEqual(APIStyle.openai_chat, cfg.style()); } diff --git a/libpanto/src/provider.zig b/libpanto/src/provider.zig index 0fe0ed4..8ea74f3 100644 --- a/libpanto/src/provider.zig +++ b/libpanto/src/provider.zig @@ -243,13 +243,14 @@ pub const ProviderStream = struct { /// `*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. On success the caller owns the returned `ProviderStream` and -/// must `deinit` it. +/// The tool registry is supplied by the caller (the `Agent` owns it now, +/// not `cfg`); the serializers receive it directly. On success the caller +/// owns the returned `ProviderStream` and must `deinit` it. pub fn openStream( allocator: std.mem.Allocator, io: std.Io, cfg: *const config_mod.Config, + registry: *const ToolRegistry, conv: *conversation.Conversation, diag: ?*ProviderDiagnostic, ) anyerror!ProviderStream { @@ -267,7 +268,7 @@ pub fn openStream( .http_client = client, .diag = diag, }; - const rr = try req.open(conv, cfg.registry); + const rr = try req.open(conv, registry); return rr.providerStream(); }, .anthropic_messages => |*c| { @@ -278,7 +279,7 @@ pub fn openStream( .http_client = client, .diag = diag, }; - const rr = try req.open(conv, cfg.registry); + const rr = try req.open(conv, registry); return rr.providerStream(); }, } @@ -291,6 +292,7 @@ pub const OpenStreamFn = *const fn ( allocator: std.mem.Allocator, io: std.Io, cfg: *const config_mod.Config, + registry: *const ToolRegistry, conv: *conversation.Conversation, diag: ?*ProviderDiagnostic, ) anyerror!ProviderStream; diff --git a/src/main.zig b/src/main.zig index ff00689..46b6320 100644 --- a/src/main.zig +++ b/src/main.zig @@ -365,21 +365,16 @@ pub fn main(init: std.process.Init) !void { ); } - // The tool registry is owned here and referenced by the active config - // snapshot. Extensions register their tool source into it *after* the - // agent is built; the agent holds a `*const Config` pointing at this - // registry, so in-place registration before the first turn is visible. - var registry = panto.ToolRegistry.init(alloc); - defer registry.deinit(); - // Assemble the active configuration snapshot and build the agent now, // before luarocks bootstrap and extension loading. The agent adopts the // store (for free persistence) and the resumed conversation (or starts - // fresh). System-prompt seeding/reconciliation below runs *through* the - // agent so those entries persist. + // fresh). The agent owns its own tool registry (post-R1); extensions + // register their tool source onto it *after* the agent is built but + // before the first turn, so in-place registration is visible. + // System-prompt seeding/reconciliation below runs *through* the agent + // so those entries persist. const active_config: panto.config.Config = .{ .provider = provider_config, - .registry = ®istry, .compaction = compaction_cfg, }; var agent = panto.agent.Agent.init( @@ -468,7 +463,7 @@ pub fn main(init: std.process.Init) !void { std.log.debug("extensions: {d} tool(s) registered", .{n_ext_tools}); if (n_ext_tools > 0) { - try registry.registerSource(rt.toolSource()); + try agent.registerToolSource(rt.toolSource()); } // Resolve the compaction system prompt (COMPACTION.md across layers, diff --git a/src/system_prompt.zig b/src/system_prompt.zig index b5423f8..6104034 100644 --- a/src/system_prompt.zig +++ b/src/system_prompt.zig @@ -467,12 +467,12 @@ fn openTmpSession(arena: Allocator, root: []const u8) !panto.session_manager.Ses return panto.session_manager.SessionManager.init(testing.allocator, testing.io, sessions, "/cwd"); } -/// Minimal agent harness for system-prompt tests: an empty registry and a -/// throwaway provider config, wrapping a session store and (optionally) an -/// adopted conversation. Holds the registry/config so the agent's borrowed -/// pointers stay valid for the agent's lifetime. +/// Minimal agent harness for system-prompt tests: a throwaway provider +/// config wrapping a session store and (optionally) an adopted +/// conversation. Post-R1 the agent owns its own (empty) tool registry, so +/// the harness only holds the config to keep the agent's borrowed pointer +/// valid for its lifetime. const SPAgentHarness = struct { - registry: panto.ToolRegistry, config: panto.config.Config, agent: panto.agent.Agent, @@ -481,17 +481,14 @@ const SPAgentHarness = struct { store: panto.session_store.SessionStore, adopted: ?panto.conversation.Conversation, ) void { - self.registry = panto.ToolRegistry.init(testing.allocator); self.config = .{ .provider = .{ .openai_chat = .{ .api_key = "k", .base_url = "u", .model = "m" } }, - .registry = &self.registry, }; self.agent = panto.agent.Agent.init(testing.allocator, testing.io, &self.config, store, adopted); } fn deinit(self: *SPAgentHarness) void { self.agent.deinit(); - self.registry.deinit(); } }; |
